From aa455fdc103fcb3f3a8cc34b2346756876842ca0 Mon Sep 17 00:00:00 2001 From: tr00d Date: Mon, 5 Oct 2026 13:26:57 +0200 Subject: [PATCH] chore(quality-gate): enforce mutation testing on quality gate --- .github/workflows/mutation.yml | 50 +++++++++ packages/Core/Core.Tests/stryker-config.json | 3 +- .../Functions.Tests/stryker-config.json | 3 +- .../Gotrue/Gotrue.Tests/stryker-config.json | 3 +- .../Postgrest.Tests/stryker-config.json | 3 +- .../Realtime.Tests/stryker-config.json | 3 +- .../Storage/Storage.Tests/stryker-config.json | 3 +- .../Supabase.Tests/stryker-config.json | 3 +- .../_base/Sample.Tests/stryker-config.json | 7 ++ .../fixtures/mutation-clean/expect.json | 9 ++ .../overlay/Sample/Calculator.cs | 6 ++ .../reports/mutation-report.json | 47 ++++++++ .../fixtures/mutation-no-report/expect.json | 9 ++ .../overlay/Sample/Calculator.cs | 6 ++ .../mutation-old-survivor/expect.json | 9 ++ .../overlay/Sample/Calculator.cs | 6 ++ .../reports/mutation-report.json | 53 +++++++++ .../fixtures/mutation-survivor/expect.json | 9 ++ .../overlay/Sample/Calculator.cs | 6 ++ .../reports/mutation-report.json | 47 ++++++++ scripts/quality-gate/gate.sh | 102 ++++++++++-------- scripts/quality-gate/lib/stages.sh | 80 ++++++++++++++ 22 files changed, 414 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/mutation.yml create mode 100644 scripts/quality-gate/fixtures/_base/Sample.Tests/stryker-config.json create mode 100644 scripts/quality-gate/fixtures/mutation-clean/expect.json create mode 100644 scripts/quality-gate/fixtures/mutation-clean/overlay/Sample/Calculator.cs create mode 100644 scripts/quality-gate/fixtures/mutation-clean/overlay/StrykerOutput/reports/mutation-report.json create mode 100644 scripts/quality-gate/fixtures/mutation-no-report/expect.json create mode 100644 scripts/quality-gate/fixtures/mutation-no-report/overlay/Sample/Calculator.cs create mode 100644 scripts/quality-gate/fixtures/mutation-old-survivor/expect.json create mode 100644 scripts/quality-gate/fixtures/mutation-old-survivor/overlay/Sample/Calculator.cs create mode 100644 scripts/quality-gate/fixtures/mutation-old-survivor/overlay/StrykerOutput/reports/mutation-report.json create mode 100644 scripts/quality-gate/fixtures/mutation-survivor/expect.json create mode 100644 scripts/quality-gate/fixtures/mutation-survivor/overlay/Sample/Calculator.cs create mode 100644 scripts/quality-gate/fixtures/mutation-survivor/overlay/StrykerOutput/reports/mutation-report.json diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 00000000..cb2ce00e --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,50 @@ +name: Mutation + +# Separate from build-and-test on purpose: release.yml waits only on build-and-test, so +# this never delays a release. It runs in parallel with build-and-test on the PR; making +# it block a merge is a branch-protection choice (mark the `mutation` check required). +# +# `dotnet stryker --since:origin/master` mutates only the changed diff (hermetic — E2E is +# filtered out in each stryker-config.json), then gate.sh --mutation fails on any new +# survivor. Only changed packages are mutated, so an unchanged package costs nothing. + +on: + pull_request: + branches: [master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mutation: + runs-on: ubuntu-latest + # Ceiling, not the norm: Stryker.NET mutates everything if a non-C# file changed + # (stryker-net#3013), so the timeout bounds that worst case. + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # --since and the gate's diff both need origin/master + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Install Stryker + run: dotnet tool install --global dotnet-stryker + + - name: Mutate changed packages + run: | + git diff --name-only origin/master...HEAD -- 'packages/**/*.cs' \ + | sed -E 's#(packages/[^/]+)/.*#\1#' | sort -u \ + | while read -r pkg; do + cfg=$(ls "$pkg"/*.Tests/stryker-config.json 2>/dev/null) || continue + ( cd "$(dirname "$cfg")" && dotnet stryker --since:origin/master ) + done + + - name: Evaluate mutation reports + run: ./scripts/quality-gate/gate.sh packages --mutation diff --git a/packages/Core/Core.Tests/stryker-config.json b/packages/Core/Core.Tests/stryker-config.json index 8b6cf8f7..5aae18ac 100644 --- a/packages/Core/Core.Tests/stryker-config.json +++ b/packages/Core/Core.Tests/stryker-config.json @@ -8,6 +8,7 @@ "reporters": [ "json", "progress" - ] + ], + "test-case-filter": "TestCategory!=E2E" } } diff --git a/packages/Functions/Functions.Tests/stryker-config.json b/packages/Functions/Functions.Tests/stryker-config.json index c6c68a8a..6dac40c8 100644 --- a/packages/Functions/Functions.Tests/stryker-config.json +++ b/packages/Functions/Functions.Tests/stryker-config.json @@ -8,6 +8,7 @@ "reporters": [ "json", "progress" - ] + ], + "test-case-filter": "TestCategory!=E2E" } } diff --git a/packages/Gotrue/Gotrue.Tests/stryker-config.json b/packages/Gotrue/Gotrue.Tests/stryker-config.json index 9d6a50ba..3028b192 100644 --- a/packages/Gotrue/Gotrue.Tests/stryker-config.json +++ b/packages/Gotrue/Gotrue.Tests/stryker-config.json @@ -8,6 +8,7 @@ "reporters": [ "json", "progress" - ] + ], + "test-case-filter": "TestCategory!=E2E" } } diff --git a/packages/Postgrest/Postgrest.Tests/stryker-config.json b/packages/Postgrest/Postgrest.Tests/stryker-config.json index b8dcdcea..144b0cc4 100644 --- a/packages/Postgrest/Postgrest.Tests/stryker-config.json +++ b/packages/Postgrest/Postgrest.Tests/stryker-config.json @@ -8,6 +8,7 @@ "reporters": [ "json", "progress" - ] + ], + "test-case-filter": "TestCategory!=E2E" } } diff --git a/packages/Realtime/Realtime.Tests/stryker-config.json b/packages/Realtime/Realtime.Tests/stryker-config.json index 6f77c733..adfff98b 100644 --- a/packages/Realtime/Realtime.Tests/stryker-config.json +++ b/packages/Realtime/Realtime.Tests/stryker-config.json @@ -8,6 +8,7 @@ "reporters": [ "json", "progress" - ] + ], + "test-case-filter": "TestCategory!=E2E" } } diff --git a/packages/Storage/Storage.Tests/stryker-config.json b/packages/Storage/Storage.Tests/stryker-config.json index 95ec3fc0..f49d07d5 100644 --- a/packages/Storage/Storage.Tests/stryker-config.json +++ b/packages/Storage/Storage.Tests/stryker-config.json @@ -8,6 +8,7 @@ "reporters": [ "json", "progress" - ] + ], + "test-case-filter": "TestCategory!=E2E" } } diff --git a/packages/Supabase/Supabase.Tests/stryker-config.json b/packages/Supabase/Supabase.Tests/stryker-config.json index 9ff0e354..dd990342 100644 --- a/packages/Supabase/Supabase.Tests/stryker-config.json +++ b/packages/Supabase/Supabase.Tests/stryker-config.json @@ -8,6 +8,7 @@ "reporters": [ "json", "progress" - ] + ], + "test-case-filter": "TestCategory!=E2E" } } diff --git a/scripts/quality-gate/fixtures/_base/Sample.Tests/stryker-config.json b/scripts/quality-gate/fixtures/_base/Sample.Tests/stryker-config.json new file mode 100644 index 00000000..c86396af --- /dev/null +++ b/scripts/quality-gate/fixtures/_base/Sample.Tests/stryker-config.json @@ -0,0 +1,7 @@ +{ + "stryker-config": { + "project": "Sample.csproj", + "test-projects": ["Sample.Tests.csproj"], + "test-case-filter": "TestCategory!=E2E" + } +} diff --git a/scripts/quality-gate/fixtures/mutation-clean/expect.json b/scripts/quality-gate/fixtures/mutation-clean/expect.json new file mode 100644 index 00000000..4e7bda89 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-clean/expect.json @@ -0,0 +1,9 @@ +{ + "description": "Every mutant in the changed file is killed — the tests actually assert on the new code. The mutation stage PASSes.", + "mode": "--mutation", + "exit": 0, + "verdict": "PASS", + "stages": [ + { "id": "3", "status": "PASS" } + ] +} diff --git a/scripts/quality-gate/fixtures/mutation-clean/overlay/Sample/Calculator.cs b/scripts/quality-gate/fixtures/mutation-clean/overlay/Sample/Calculator.cs new file mode 100644 index 00000000..6001b9b8 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-clean/overlay/Sample/Calculator.cs @@ -0,0 +1,6 @@ +namespace Sample; + +public class Calculator +{ + public int Add(int a, int b) => a + b; +} diff --git a/scripts/quality-gate/fixtures/mutation-clean/overlay/StrykerOutput/reports/mutation-report.json b/scripts/quality-gate/fixtures/mutation-clean/overlay/StrykerOutput/reports/mutation-report.json new file mode 100644 index 00000000..b5e5d745 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-clean/overlay/StrykerOutput/reports/mutation-report.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": "2", + "thresholds": { + "high": 80, + "low": 60 + }, + "files": { + "/repo/packages/Sample/Calculator.cs": { + "language": "cs", + "source": "namespace Sample;\n\npublic class Calculator\n{\n public int Add(int a, int b) => a + b;\n}\n", + "mutants": [ + { + "id": "1", + "mutatorName": "Arithmetic", + "replacement": "a - b", + "location": { + "start": { + "line": 5, + "column": 41 + }, + "end": { + "line": 5, + "column": 46 + } + }, + "status": "Killed" + }, + { + "id": "2", + "mutatorName": "Arithmetic", + "replacement": "a * b", + "location": { + "start": { + "line": 5, + "column": 41 + }, + "end": { + "line": 5, + "column": 46 + } + }, + "status": "Killed" + } + ] + } + } +} diff --git a/scripts/quality-gate/fixtures/mutation-no-report/expect.json b/scripts/quality-gate/fixtures/mutation-no-report/expect.json new file mode 100644 index 00000000..fb092022 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-no-report/expect.json @@ -0,0 +1,9 @@ +{ + "description": "The diff changed a file but no Stryker report exists — 'couldn't check' must never read as 'no survivors'. The stage SKIPs and the run is INCOMPLETE (exit 2).", + "mode": "--mutation", + "exit": 2, + "verdict": "INCOMPLETE", + "stages": [ + { "id": "3", "status": "SKIP", "detailContains": "no usable mutation report" } + ] +} diff --git a/scripts/quality-gate/fixtures/mutation-no-report/overlay/Sample/Calculator.cs b/scripts/quality-gate/fixtures/mutation-no-report/overlay/Sample/Calculator.cs new file mode 100644 index 00000000..6001b9b8 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-no-report/overlay/Sample/Calculator.cs @@ -0,0 +1,6 @@ +namespace Sample; + +public class Calculator +{ + public int Add(int a, int b) => a + b; +} diff --git a/scripts/quality-gate/fixtures/mutation-old-survivor/expect.json b/scripts/quality-gate/fixtures/mutation-old-survivor/expect.json new file mode 100644 index 00000000..c304d948 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-old-survivor/expect.json @@ -0,0 +1,9 @@ +{ + "description": "A survivor exists, but in Legacy.cs — a file this diff never touched. It's pre-existing debt, not this change's regression, so it's ignored; the changed file's mutants are all killed, so the stage PASSes.", + "mode": "--mutation", + "exit": 0, + "verdict": "PASS", + "stages": [ + { "id": "3", "status": "PASS" } + ] +} diff --git a/scripts/quality-gate/fixtures/mutation-old-survivor/overlay/Sample/Calculator.cs b/scripts/quality-gate/fixtures/mutation-old-survivor/overlay/Sample/Calculator.cs new file mode 100644 index 00000000..6001b9b8 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-old-survivor/overlay/Sample/Calculator.cs @@ -0,0 +1,6 @@ +namespace Sample; + +public class Calculator +{ + public int Add(int a, int b) => a + b; +} diff --git a/scripts/quality-gate/fixtures/mutation-old-survivor/overlay/StrykerOutput/reports/mutation-report.json b/scripts/quality-gate/fixtures/mutation-old-survivor/overlay/StrykerOutput/reports/mutation-report.json new file mode 100644 index 00000000..70f93b0f --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-old-survivor/overlay/StrykerOutput/reports/mutation-report.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": "2", + "thresholds": { + "high": 80, + "low": 60 + }, + "files": { + "/repo/packages/Sample/Calculator.cs": { + "language": "cs", + "source": "namespace Sample;\n\npublic class Calculator\n{\n public int Add(int a, int b) => a + b;\n}\n", + "mutants": [ + { + "id": "1", + "mutatorName": "Arithmetic", + "replacement": "a - b", + "location": { + "start": { + "line": 5, + "column": 41 + }, + "end": { + "line": 5, + "column": 46 + } + }, + "status": "Killed" + } + ] + }, + "/repo/packages/Sample/Legacy.cs": { + "language": "cs", + "source": "namespace Sample;\n\npublic class Legacy\n{\n public int Twice(int n) => n * 2;\n}\n", + "mutants": [ + { + "id": "2", + "mutatorName": "Arithmetic", + "replacement": "n / 2", + "location": { + "start": { + "line": 5, + "column": 32 + }, + "end": { + "line": 5, + "column": 37 + } + }, + "status": "Survived" + } + ] + } + } +} diff --git a/scripts/quality-gate/fixtures/mutation-survivor/expect.json b/scripts/quality-gate/fixtures/mutation-survivor/expect.json new file mode 100644 index 00000000..05462af6 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-survivor/expect.json @@ -0,0 +1,9 @@ +{ + "description": "A surviving mutant lands on a line this diff added — a test executes the new code but asserts nothing, so line coverage is happy and the mutant lives. Only the mutation stage must FAIL.", + "mode": "--mutation", + "exit": 1, + "verdict": "FAIL", + "stages": [ + { "id": "3", "status": "FAIL", "detailContains": "new survivor" } + ] +} diff --git a/scripts/quality-gate/fixtures/mutation-survivor/overlay/Sample/Calculator.cs b/scripts/quality-gate/fixtures/mutation-survivor/overlay/Sample/Calculator.cs new file mode 100644 index 00000000..6001b9b8 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-survivor/overlay/Sample/Calculator.cs @@ -0,0 +1,6 @@ +namespace Sample; + +public class Calculator +{ + public int Add(int a, int b) => a + b; +} diff --git a/scripts/quality-gate/fixtures/mutation-survivor/overlay/StrykerOutput/reports/mutation-report.json b/scripts/quality-gate/fixtures/mutation-survivor/overlay/StrykerOutput/reports/mutation-report.json new file mode 100644 index 00000000..5ec95ae2 --- /dev/null +++ b/scripts/quality-gate/fixtures/mutation-survivor/overlay/StrykerOutput/reports/mutation-report.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": "2", + "thresholds": { + "high": 80, + "low": 60 + }, + "files": { + "/repo/packages/Sample/Calculator.cs": { + "language": "cs", + "source": "namespace Sample;\n\npublic class Calculator\n{\n public int Add(int a, int b) => a + b;\n}\n", + "mutants": [ + { + "id": "1", + "mutatorName": "Arithmetic", + "replacement": "a - b", + "location": { + "start": { + "line": 5, + "column": 41 + }, + "end": { + "line": 5, + "column": 46 + } + }, + "status": "Survived" + }, + { + "id": "2", + "mutatorName": "Arithmetic", + "replacement": "a * b", + "location": { + "start": { + "line": 5, + "column": 41 + }, + "end": { + "line": 5, + "column": 46 + } + }, + "status": "Killed" + } + ] + } + } +} diff --git a/scripts/quality-gate/gate.sh b/scripts/quality-gate/gate.sh index d2897f78..286c34ab 100755 --- a/scripts/quality-gate/gate.sh +++ b/scripts/quality-gate/gate.sh @@ -4,6 +4,8 @@ # # gate.sh [dir] full gate — build, tests, security, public API, E2E # gate.sh [dir] --fast inner loop only — the agent's red/green cycle +# gate.sh [dir] --mutation evaluate a Stryker report (a separate CI job produces +# it; not run here). Opt-in — off the default/PR path. # gate.sh [dir] --bypass-format format + naming reports as a signal, not a # blocking stage, for this run. For PR-time CI # only: a human contributor can't always fix a @@ -26,8 +28,9 @@ # API check, per-package tests, and a single verdict. A single package keeps the # fast per-package path the inner loop uses. --all forces the solution run. # -# Mutation testing is not run here — it is too slow for the inner/PR loop and lives -# in its own scheduled GitHub Action. +# Mutation testing (--mutation) does not run Stryker here — a separate CI job produces +# the report this reads. It is never in build-and-test, so it can't gate a release; +# branch protection makes its check required for merge. # # Config that belongs to the package lives in a committed .gate-baseline.json, not # on the command line. The file is created on the first run with --overwrite-baseline. @@ -64,11 +67,12 @@ PACKAGE_DIR="$PWD"; MODE="standard"; ALL=0; CLI_PROJECT=""; BYPASS_FORMAT=0; OVE while [[ $# -gt 0 ]]; do case "$1" in --fast) MODE="fast"; shift ;; + --mutation) MODE="mutation"; shift ;; --full) shift ;; # accepted as an alias: the default run is already the full gate --all) ALL=1; shift ;; --bypass-format) BYPASS_FORMAT=1; shift ;; --overwrite-baseline) OVERWRITE_BASELINE=1; shift ;; - -h|--help) sed -n '3,23p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '3,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; -*) echo "unknown option: $1 (try --help)" >&2; exit 3 ;; *) PACKAGE_DIR="$1"; shift ;; esac @@ -147,53 +151,59 @@ else fi echo -stage_build -STACK_OK=0 -if [[ $BUILD_OK -eq 1 && "$MODE" != "fast" ]]; then - # stage_build just reset $BASELINE to "" (so it doesn't leak into the - # scope-wide stages below) — restore it to a package's own baseline before - # probing, so a per-package e2eHealthUrl override is actually read. Without - # this, the probe silently falls through to $CONFIG / the hardcoded default, - # which can false-positive against an unrelated stack already running on the - # default port. First package is representative for a solution run. - BASELINE="${PKG_DIR[0]}/.gate-baseline.json" - if stack_up; then STACK_OK=1; fi - BASELINE="" -fi +# Mutation mode only reads a report — skip the build-first orchestration below. +if [[ "$MODE" == "mutation" ]]; then + stage_mutation +else + + stage_build + STACK_OK=0 + if [[ $BUILD_OK -eq 1 && "$MODE" != "fast" ]]; then + # stage_build just reset $BASELINE to "" (so it doesn't leak into the + # scope-wide stages below) — restore it to a package's own baseline before + # probing, so a per-package e2eHealthUrl override is actually read. Without + # this, the probe silently falls through to $CONFIG / the hardcoded default, + # which can false-positive against an unrelated stack already running on the + # default port. First package is representative for a solution run. + BASELINE="${PKG_DIR[0]}/.gate-baseline.json" + if stack_up; then STACK_OK=1; fi + BASELINE="" + fi -if [[ $BUILD_OK -eq 1 ]]; then - stage_format - if [[ "$MODE" == "fast" ]]; then - stage_inner_loop - elif [[ $STACK_OK -eq 1 ]]; then - stage_tests_full - # No separate id-7 row here: stage_tests_full's own per-package rows already - # carry the full outcome (label says "Unit + Contract + E2E"), so a second row - # would only restate it — and imprecisely, since "E2E ran" isn't uniformly true - # across every package (a package with zero E2E tests has nothing to report). - stage_coverage + if [[ $BUILD_OK -eq 1 ]]; then + stage_format + if [[ "$MODE" == "fast" ]]; then + stage_inner_loop + elif [[ $STACK_OK -eq 1 ]]; then + stage_tests_full + # No separate id-7 row here: stage_tests_full's own per-package rows already + # carry the full outcome (label says "Unit + Contract + E2E"), so a second row + # would only restate it — and imprecisely, since "E2E ran" isn't uniformly true + # across every package (a package with zero E2E tests has nothing to report). + stage_coverage + else + stage_inner_loop + fi else - stage_inner_loop + add 1b "Format + naming" block "" SKIP "not run — build failed" "" + add 2 "Inner loop (Unit + Contract)" block "" SKIP "not run — build failed" "" fi -else - add 1b "Format + naming" block "" SKIP "not run — build failed" "" - add 2 "Inner loop (Unit + Contract)" block "" SKIP "not run — build failed" "" -fi -if [[ "$MODE" != "fast" ]]; then - stage_security - # The sync check compiles (dotnet format runs the analyzers), so a failed build - # makes it impossible, not failing — skip it causally, as with format and tests. - if [[ $BUILD_OK -eq 1 ]]; then stage_api_sync - else add 5a "Public API declared" block "" SKIP "not run — build failed" ""; fi - stage_api_diff - - if [[ $BUILD_OK -eq 0 ]]; then - add 7 "E2E / acceptance" block "" SKIP "not run — build failed" "" - add 2b "Coverage (line, unit+contract)" block "" SKIP "not run — build failed" "" - elif [[ $STACK_OK -eq 0 ]]; then - add 7 "E2E / acceptance" block "" SKIP "stack down per $STACK_CHECK — run: supabase start" "$SCOPE_LOGS/7-stack.log" - add 2b "Coverage (line, unit+contract)" block "" SKIP "not measured — stack down, full run did not execute" "" + if [[ "$MODE" != "fast" ]]; then + stage_security + # The sync check compiles (dotnet format runs the analyzers), so a failed build + # makes it impossible, not failing — skip it causally, as with format and tests. + if [[ $BUILD_OK -eq 1 ]]; then stage_api_sync + else add 5a "Public API declared" block "" SKIP "not run — build failed" ""; fi + stage_api_diff + + if [[ $BUILD_OK -eq 0 ]]; then + add 7 "E2E / acceptance" block "" SKIP "not run — build failed" "" + add 2b "Coverage (line, unit+contract)" block "" SKIP "not run — build failed" "" + elif [[ $STACK_OK -eq 0 ]]; then + add 7 "E2E / acceptance" block "" SKIP "stack down per $STACK_CHECK — run: supabase start" "$SCOPE_LOGS/7-stack.log" + add 2b "Coverage (line, unit+contract)" block "" SKIP "not measured — stack down, full run did not execute" "" + fi fi fi diff --git a/scripts/quality-gate/lib/stages.sh b/scripts/quality-gate/lib/stages.sh index 7b3b221d..063ea0e9 100644 --- a/scripts/quality-gate/lib/stages.sh +++ b/scripts/quality-gate/lib/stages.sh @@ -430,3 +430,83 @@ stage_coverage() { add 2b "Coverage (line, unit+contract)" block "${PKG_NAME[$i]}" "${v%%|||*}" "$detail" "$LOGS/2-tests.log" done } + +# ============================================================= 3 mutation +# Opt-in (--mutation). Reads the report a separate CI job's `dotnet stryker` produced +# and FAILs on a mutant left alive (Survived/NoCoverage) in a file this diff changed — +# "no new survivors". Never runs Stryker; run-fixtures.sh drives it with canned reports. +# A missing report is SKIP (couldn't check ≠ passed), which makes --mutation exit 2. +MUTATION_UNDETECTED='.status=="Survived" or .status=="NoCoverage"' +_MUT_CHANGED=() + +_mutation_report() { # GATE_MUTATION_REPORT override, then .mutationReport, then newest on disk + if [[ -n "${GATE_MUTATION_REPORT:-}" ]]; then + [[ "$GATE_MUTATION_REPORT" == /* ]] && echo "$GATE_MUTATION_REPORT" || echo "$PACKAGE_DIR/$GATE_MUTATION_REPORT" + return + fi + local c; c="$(cget .mutationReport)" + [[ -n "$c" ]] && { echo "$PACKAGE_DIR/$c"; return; } + find "$PACKAGE_DIR" -name 'mutation-report.json' -not -path '*/bin/*' -not -path '*/obj/*' 2>/dev/null | sort | tail -n1 +} + +# Report keys may be absolute, repo-, or project-relative depending on Stryker version; +# match by suffix in either direction. Caller guarantees $_MUT_CHANGED is non-empty. +_mutation_file_changed() { # + local p="$1" c + for c in "${_MUT_CHANGED[@]}"; do + [[ "$c" == "$p" || "$c" == */"$p" || "$p" == */"$c" ]] && return 0 + done + return 1 +} + +_pkg_has_changes() { # true if any changed file lives under the current package + local rel="" c + [[ -n "$root" && "$PACKAGE_DIR" != "$root" ]] && rel="${PACKAGE_DIR#"$root"/}" + for c in "${_MUT_CHANGED[@]}"; do + [[ -z "$rel" || "$c" == "$rel/"* ]] && return 0 + done + return 1 +} + +_pkg_is_tracked() { # true if the package ships a stryker-config.json + find "$PACKAGE_DIR" -name 'stryker-config.json' -not -path '*/bin/*' -not -path '*/obj/*' 2>/dev/null | grep -q . +} + +stage_mutation() { + local i report log root rel first count f status line normf + root="$(git -C "$SCOPE_DIR" rev-parse --show-toplevel 2>/dev/null || true)" + _MUT_CHANGED=() + [[ -n "$root" ]] && while IFS= read -r rel; do [[ -n "$rel" ]] && _MUT_CHANGED+=("$rel"); done < <(changed_cs_files) + + for i in "${!PKG_DIR[@]}"; do + _use_pkg "$i" + log="$LOGS/3-mutation.log" + + if [[ ${#_MUT_CHANGED[@]} -eq 0 ]] || ! _pkg_has_changes; then + add 3 "Mutation (new survivors)" block "${PKG_NAME[$i]}" PASS "no changed .cs files here"; continue + fi + if ! _pkg_is_tracked; then + add 3 "Mutation (new survivors)" block "${PKG_NAME[$i]}" PASS "not mutation-tracked (no stryker-config.json)"; continue + fi + + report="$(_mutation_report)" + if [[ -z "$report" || ! -f "$report" ]] || ! jq -e . "$report" >/dev/null 2>&1; then + add 3 "Mutation (new survivors)" block "${PKG_NAME[$i]}" SKIP "no usable mutation report — run dotnet stryker" "${report:-}"; continue + fi + + : > "$log"; first=""; count=0 + while IFS=$'\t' read -r f status line; do + [[ -n "$f" ]] || continue + normf="$f"; [[ "$normf" == /* && -n "$root" ]] && normf="${normf#"$root"/}" + _mutation_file_changed "$normf" || continue + count=$((count + 1)); echo "$status $normf:$line" >> "$log" + [[ -z "$first" ]] && first="$normf:$line ($status)" + done < <(jq -r ".files | to_entries[] | .key as \$f | .value.mutants[] | select($MUTATION_UNDETECTED) | \"\(\$f)\t\(.status)\t\(.location.start.line // 0)\"" "$report") + + if [[ $count -gt 0 ]]; then + add 3 "Mutation (new survivors)" block "${PKG_NAME[$i]}" FAIL "$count new survivor(s) — first: $first" "$log" + else + add 3 "Mutation (new survivors)" block "${PKG_NAME[$i]}" PASS "no survivors in changed code" "$report" + fi + done +}