From 75875a74db44df456e2c55942fa2892f19ac8fad Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:41:06 +0200 Subject: [PATCH 1/7] chore(ci): harden Stryker mutation plumbing --- .github/workflows/mutation.yml | 85 ++++++-------- package.json | 12 +- pnpm-lock.yaml | 18 --- scripts/aggregate-stryker-reports.mjs | 105 ++++++++++++++++++ scripts/assert-ci-only.mjs | 8 ++ scripts/stryker-scope.mjs | 56 ++++++++++ stryker-scope.json | 66 +++++++++++ stryker.conf.json | 69 ------------ stryker.config.mjs | 44 ++++++++ tests/unit/tooling/strykerAggregation.test.ts | 76 +++++++++++++ .../tooling/strykerWorkflowPolicy.test.ts | 28 +++++ 11 files changed, 421 insertions(+), 146 deletions(-) create mode 100644 scripts/aggregate-stryker-reports.mjs create mode 100644 scripts/assert-ci-only.mjs create mode 100644 scripts/stryker-scope.mjs create mode 100644 stryker-scope.json delete mode 100644 stryker.conf.json create mode 100644 stryker.config.mjs create mode 100644 tests/unit/tooling/strykerAggregation.test.ts create mode 100644 tests/unit/tooling/strykerWorkflowPolicy.test.ts diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 5fa3a32a1..e8fc34018 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -1,6 +1,6 @@ # ============================================================ # WorldScript Studio – Mutation Testing (Stryker) — Parallel + Incremental -# QNBS-v3: Matrix-parallel Stryker with per-module incremental caching. +# QNBS-v3: Matrix-parallel Stryker with per-module caches and fail-safe aggregation. # Run manually via workflow_dispatch. Each matrix job targets one module # to keep wall-clock time low (~5–15 min per job vs. 30–45 min single job). # Incremental caching skips unchanged mutants on re-runs (50–80% speedup). @@ -33,6 +33,21 @@ permissions: contents: read jobs: + scope: + name: 🧬 Validate Stryker Scope + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.scope.outputs.matrix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: scope + shell: bash + run: | + node scripts/stryker-scope.mjs + echo "matrix=$(node scripts/stryker-scope.mjs --matrix)" >> "$GITHUB_OUTPUT" + # ============================================ # PARALLEL MATRIX JOBS (one per module) # ============================================ @@ -40,24 +55,10 @@ jobs: name: 🧬 Stryker ${{ matrix.name }} runs-on: ubuntu-latest timeout-minutes: 45 + needs: scope strategy: fail-fast: false - matrix: - include: - - name: services-commands - mutate: "services/commands/**/*.ts" - - name: services-core - mutate: "services/{help/helpDocRetrieval,plotBoardService,promptLibrary,deepLinkService}.ts" - - name: services-ai-core - mutate: "services/ai/{modelRecommendations,aiPolicy,aiRetry,fetchAdapter,routingLogger,aiModeService}.ts" - - name: services-ai-providers - mutate: "services/ai/providers/openrouterProvider.ts" - - name: features-project - mutate: "features/project/**/*.ts" - - name: features-misc - mutate: "features/{settings,progressTracker,featureFlags,proForge}/**/*.ts" - - name: copilot - mutate: "services/copilot/**/*.ts" + matrix: ${{ fromJSON(needs.scope.outputs.matrix) }} permissions: contents: read @@ -107,10 +108,13 @@ jobs: echo " Concurrency: $CONCURRENCY" echo " Mode: $MODE" - # QNBS-v3: each matrix job uses its own incremental file - export STRYKER_INCREMENTAL_FILE="reports/stryker-incremental-${{ matrix.name }}.json" + # QNBS-v3: pass the supported CLI option; arbitrary env vars are not Stryker config. + INCREMENTAL_FILE="reports/stryker-incremental-${{ matrix.name }}.json" pnpm exec stryker run \ + stryker.config.mjs \ + --incremental \ + --incrementalFile "$INCREMENTAL_FILE" \ $FORCE_FLAG \ --concurrency "$CONCURRENCY" \ --mutate "${{ matrix.mutate }}" \ @@ -124,7 +128,7 @@ jobs: with: name: stryker-report-${{ matrix.name }} path: reports/mutation/ - if-no-files-found: warn + if-no-files-found: error retention-days: 30 # ============================================ @@ -133,50 +137,31 @@ jobs: aggregate: name: 📊 Stryker Aggregate Report runs-on: ubuntu-latest - needs: stryker + needs: [scope, stryker] if: always() timeout-minutes: 10 permissions: contents: read steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download all Stryker reports uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: all-reports pattern: stryker-report-* - merge-multiple: true + merge-multiple: false - name: Build combined summary run: | - echo "## 🧬 Stryker Mutation Results (Parallel)" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Module | Score | Killed | Survived | Timeout | No Cov |" >> "$GITHUB_STEP_SUMMARY" - echo "|--------|-------|--------|----------|---------|--------|" >> "$GITHUB_STEP_SUMMARY" - - for dir in all-reports/stryker-report-*/; do - [ -d "$dir" ] || continue - MODULE=$(basename "$dir" | sed 's/stryker-report-//') - REPORT="${dir}mutation.json" - - if [ -f "$REPORT" ]; then - SCORE=$(node -e " - const r = require('./$REPORT'); - const m = r.metrics || {}; - console.log((m.mutationScore ?? 0).toFixed(1) + '%'); - " 2>/dev/null || echo "N/A") - KILLED=$(node -e "const r=require('./$REPORT'); console.log(r.metrics?.killed ?? 'N/A')" 2>/dev/null || echo "N/A") - SURVIVED=$(node -e "const r=require('./$REPORT'); console.log(r.metrics?.survived ?? 'N/A')" 2>/dev/null || echo "N/A") - TIMEOUT=$(node -e "const r=require('./$REPORT'); console.log(r.metrics?.timeout ?? 'N/A')" 2>/dev/null || echo "N/A") - NOCOV=$(node -e "const r=require('./$REPORT'); console.log(r.metrics?.noCoverage ?? 'N/A')" 2>/dev/null || echo "N/A") - - echo "| $MODULE | $SCORE | $KILLED | $SURVIVED | $TIMEOUT | $NOCOV |" >> "$GITHUB_STEP_SUMMARY" - else - echo "| $MODULE | N/A | N/A | N/A | N/A | N/A |" >> "$GITHUB_STEP_SUMMARY" - fi - done - - echo "" >> "$GITHUB_STEP_SUMMARY" + if [ "${{ needs.stryker.result }}" != "success" ]; then + echo "Stryker matrix did not complete successfully: ${{ needs.stryker.result }}" >&2 + exit 1 + fi + node scripts/aggregate-stryker-reports.mjs all-reports | tee -a "$GITHUB_STEP_SUMMARY" echo "> Mode: \`${{ github.event.inputs.mode || 'incremental' }}\` | Concurrency: \`${{ github.event.inputs.per_job_concurrency || '2' }}\`" >> "$GITHUB_STEP_SUMMARY" echo "> Threshold: break ≥ 75 | low ≥ 70 | high ≥ 85" >> "$GITHUB_STEP_SUMMARY" echo "> Individual HTML/JSON reports are available as artifacts." >> "$GITHUB_STEP_SUMMARY" diff --git a/package.json b/package.json index 1fd60b094..884c11c71 100644 --- a/package.json +++ b/package.json @@ -70,14 +70,9 @@ "tauri:dev": "tauri dev", "tauri:build": "tauri build", "lint": "biome lint --max-diagnostics=200 --error-on-warnings", - "mutation": "node -e \"if (process.env.CI !== 'true') { console.error('Stryker is CI-only on this machine (OOM risk). Use: gh workflow run mutation.yml'); process.exit(1); }\" && stryker run", - "mutation:fast": "node -e \"if (process.env.CI !== 'true') { console.error('Stryker is CI-only on this machine (OOM risk). Use: gh workflow run mutation.yml'); process.exit(1); }\" && stryker run --mutate 'services/commands/fuzzyScore.ts' --reporters progress", - "mutation:local": "node -e \"if (process.env.CI !== 'true') { console.error('Stryker is CI-only on this machine (OOM risk). Use: gh workflow run mutation.yml'); process.exit(1); }\" && stryker run --incremental --concurrency 1", - "mutation:full": "node -e \"if (process.env.CI !== 'true') { console.error('Stryker is CI-only on this machine (OOM risk). Use: gh workflow run mutation.yml'); process.exit(1); }\" && stryker run --force --concurrency 6", - "mutation:one": "node -e \"if (process.env.CI !== 'true') { console.error('Stryker is CI-only on this machine (OOM risk). Use: gh workflow run mutation.yml'); process.exit(1); }\" && stryker run --mutate", - "mutation:ci": "stryker run --reporters progress,json --concurrency 3", - "mutation:full-surface": "node -e \"if (process.env.CI !== 'true') { console.error('Stryker is CI-only on this machine (OOM risk). Use: gh workflow run mutation.yml'); process.exit(1); }\" && stryker run --force --mutate 'services/**/*.ts' 'features/**/*.ts' --concurrency 4", - "mutation:check": "node -e \"if (process.env.CI !== 'true') { console.error('Stryker is CI-only on this machine (OOM risk). Use: gh workflow run mutation.yml'); process.exit(1); }\" && stryker run --mutate 'services/commands/fuzzyScore.ts' --reporters progress", + "mutation": "node scripts/assert-ci-only.mjs mutation testing && stryker run stryker.config.mjs --incremental", + "mutation:force": "node scripts/assert-ci-only.mjs mutation testing && stryker run stryker.config.mjs --incremental --force", + "mutation:report": "node scripts/aggregate-stryker-reports.mjs all-reports", "lint:turbo": "turbo run lint", "lint:fix": "biome check --write", "format": "biome format --write .", @@ -171,7 +166,6 @@ "@storybook/react-vite": "^10.5.7", "@storybook/test-runner": "^0.24.4", "@stryker-mutator/core": "^9.2.0", - "@stryker-mutator/typescript-checker": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.2.0", "@tailwindcss/vite": "^4.3.3", "@tauri-apps/cli": "^2.11.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e8f94f48..fd6dc3469 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,9 +235,6 @@ importers: '@stryker-mutator/core': specifier: ^9.2.0 version: 9.6.1(@types/node@25.9.2) - '@stryker-mutator/typescript-checker': - specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.2))(typescript@6.0.3) '@stryker-mutator/vitest-runner': specifier: ^9.2.0 version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.2))(vitest@4.1.10) @@ -2808,13 +2805,6 @@ packages: resolution: {integrity: sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==} engines: {node: '>=20.0.0'} - '@stryker-mutator/typescript-checker@9.6.1': - resolution: {integrity: sha512-dCFJDoFixFe7cbilsukb7a5jpn9JRSPef7/vgx+xfaf4gPf/neDVbci8E/YSvxmcFveuPHdeUxioocA1CKZqrg==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@stryker-mutator/core': 9.6.1 - typescript: '>=3.6' - '@stryker-mutator/util@9.6.1': resolution: {integrity: sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==} @@ -10379,14 +10369,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.2))(typescript@6.0.3)': - dependencies: - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.2) - '@stryker-mutator/util': 9.6.1 - semver: 7.7.4 - typescript: 6.0.3 - '@stryker-mutator/util@9.6.1': {} '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.2))(vitest@4.1.10)': diff --git a/scripts/aggregate-stryker-reports.mjs b/scripts/aggregate-stryker-reports.mjs new file mode 100644 index 000000000..305d94eaf --- /dev/null +++ b/scripts/aggregate-stryker-reports.mjs @@ -0,0 +1,105 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mutationModules } from './stryker-scope.mjs'; + +const metricNames = [ + 'killed', + 'survived', + 'timeout', + 'noCoverage', + 'runtimeErrors', + 'compileErrors', + 'totalDetected', + 'totalUndetected', + 'totalCovered', + 'totalValid', + 'totalInvalid', + 'totalMutants', +]; + +function readMetric(report, metricName) { + const value = report.metrics?.[metricName]; + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Stryker report has invalid metrics.${metricName}.`); + } + return value; +} + +export function readStrykerReports(rootDirectory) { + const reports = []; + const missing = []; + + for (const module of mutationModules) { + const reportPath = path.join(rootDirectory, `stryker-report-${module.name}`, 'mutation.json'); + if (!existsSync(reportPath)) { + missing.push(`${module.name}/mutation.json`); + continue; + } + let report; + try { + report = JSON.parse(readFileSync(reportPath, 'utf8')); + } catch (error) { + throw new Error(`Cannot parse ${reportPath}: ${error.message}`); + } + if (!report || typeof report.metrics !== 'object') { + throw new Error(`Stryker report has no metrics object: ${reportPath}`); + } + const metrics = Object.fromEntries(metricNames.map((name) => [name, readMetric(report, name)])); + const score = report.metrics.mutationScore; + if (typeof score !== 'number' || !Number.isFinite(score)) { + throw new Error(`Stryker report has no finite mutation score: ${reportPath}`); + } + reports.push({ name: module.name, metrics, mutationScore: score }); + } + + if (missing.length > 0) { + throw new Error(`Missing required Stryker reports: ${missing.join(', ')}`); + } + return reports; +} + +export function aggregateStrykerReports(rootDirectory) { + const reports = readStrykerReports(rootDirectory); + const totals = Object.fromEntries(metricNames.map((name) => [name, 0])); + for (const report of reports) { + for (const name of metricNames) totals[name] += report.metrics[name]; + } + const mutationScore = + totals.totalValid > 0 ? (totals.totalDetected / totals.totalValid) * 100 : 100; + return { reports, totals, mutationScore }; +} + +export function formatSummary(result) { + const lines = [ + '## 🧬 Stryker Mutation Results', + '', + '| Module | Score | Killed | Survived | Timeout | No Cov | Errors |', + '|--------|------:|-------:|---------:|--------:|-------:|-------:|', + ]; + for (const report of result.reports) { + const { metrics } = report; + const errors = metrics.runtimeErrors + metrics.compileErrors; + lines.push( + `| ${report.name} | ${report.mutationScore.toFixed(1)}% | ${metrics.killed} | ${metrics.survived} | ${metrics.timeout} | ${metrics.noCoverage} | ${errors} |`, + ); + } + lines.push( + `| **Total** | **${result.mutationScore.toFixed(1)}%** | **${result.totals.killed}** | **${result.totals.survived}** | **${result.totals.timeout}** | **${result.totals.noCoverage}** | **${result.totals.runtimeErrors + result.totals.compileErrors}** |`, + '', + '> Every expected matrix shard produced a valid report; missing or invalid reports fail this job.', + ); + return `${lines.join('\n')}\n`; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const rootDirectory = process.argv[2] ?? 'all-reports'; + try { + const result = aggregateStrykerReports(rootDirectory); + const summary = formatSummary(result); + process.stdout.write(summary); + } catch (error) { + console.error(`Stryker aggregation failed: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/assert-ci-only.mjs b/scripts/assert-ci-only.mjs new file mode 100644 index 000000000..315eaac23 --- /dev/null +++ b/scripts/assert-ci-only.mjs @@ -0,0 +1,8 @@ +const task = process.argv.slice(2).join(' ') || 'This task'; + +if (process.env.CI !== 'true') { + console.error( + `${task} is CI-only on this constrained workstation. Use: gh workflow run mutation.yml`, + ); + process.exit(1); +} diff --git a/scripts/stryker-scope.mjs b/scripts/stryker-scope.mjs new file mode 100644 index 000000000..6e3056491 --- /dev/null +++ b/scripts/stryker-scope.mjs @@ -0,0 +1,56 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const scopePath = path.join(repositoryRoot, 'stryker-scope.json'); + +export const scope = JSON.parse(readFileSync(scopePath, 'utf8')); + +function validateScope(scopeDefinition) { + if (!Array.isArray(scopeDefinition.modules) || scopeDefinition.modules.length === 0) { + throw new Error('Stryker scope must define at least one module.'); + } + + const moduleNames = new Set(); + const mutationFiles = new Set(); + for (const module of scopeDefinition.modules) { + if (!module || typeof module.name !== 'string' || !Array.isArray(module.mutate)) { + throw new Error('Every Stryker scope module needs a name and mutate array.'); + } + if (moduleNames.has(module.name)) throw new Error(`Duplicate Stryker module: ${module.name}`); + moduleNames.add(module.name); + if (module.mutate.length === 0) + throw new Error(`Stryker module has no targets: ${module.name}`); + for (const file of module.mutate) { + if (typeof file !== 'string' || mutationFiles.has(file)) { + throw new Error(`Duplicate or invalid Stryker target: ${file}`); + } + if (!existsSync(path.join(repositoryRoot, file))) { + throw new Error(`Stryker target does not exist: ${file}`); + } + mutationFiles.add(file); + } + } + return { moduleNames, mutationFiles }; +} + +const validatedScope = validateScope(scope); +export const mutationFiles = [...validatedScope.mutationFiles]; +export const mutationModules = scope.modules.map(({ name, mutate }) => ({ + name, + mutate: mutate.join(','), +})); + +// QNBS-v3: Generate the CI matrix from the same target definition used by Stryker itself. +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.includes('--matrix')) { + process.stdout.write(`${JSON.stringify(mutationModules)}\n`); + } else if (process.argv.includes('--files')) { + process.stdout.write(`${mutationFiles.join(',')}\n`); + } else { + process.stdout.write( + `Validated ${mutationFiles.length} Stryker targets across ${mutationModules.length} modules.\n`, + ); + } +} diff --git a/stryker-scope.json b/stryker-scope.json new file mode 100644 index 000000000..8e21bfa81 --- /dev/null +++ b/stryker-scope.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "modules": [ + { + "name": "services-commands", + "mutate": [ + "services/commands/fuzzyScore.ts", + "services/commands/palettePreferences.ts", + "services/commands/commandBuilder.ts" + ] + }, + { + "name": "services-core", + "mutate": [ + "services/help/helpDocRetrieval.ts", + "services/plotBoardService.ts", + "services/promptLibrary.ts", + "services/deepLinkService.ts" + ] + }, + { + "name": "services-ai-core", + "mutate": [ + "services/ai/modelRecommendations.ts", + "services/ai/aiPolicy.ts", + "services/ai/aiRetry.ts", + "services/ai/fetchAdapter.ts", + "services/ai/routingLogger.ts", + "services/ai/aiModeService.ts" + ] + }, + { + "name": "services-ai-providers", + "mutate": ["services/ai/providers/openrouterProvider.ts"] + }, + { + "name": "features-project", + "mutate": [ + "features/project/projectSelectors.ts", + "features/project/sectionRestoreHelpers.ts" + ] + }, + { + "name": "features-misc", + "mutate": [ + "features/settings/accessibilitySchema.ts", + "features/progressTracker/progressTrackerSlice.ts", + "features/featureFlags/featureFlagsSlice.ts", + "features/proForge/proForgeSlice.ts" + ] + }, + { + "name": "copilot", + "mutate": [ + "services/copilot/heuristicEngine.ts", + "services/copilot/insightGenerator.ts", + "services/copilot/actionApplier.ts", + "services/copilot/copilotContextService.ts" + ] + }, + { + "name": "services-plugin", + "mutate": ["services/pluginRegistry.ts"] + } + ] +} diff --git a/stryker.conf.json b/stryker.conf.json deleted file mode 100644 index f44b47da0..000000000 --- a/stryker.conf.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/stryker.conf.json", - "packageManager": "pnpm", - "testRunner": "vitest", - "vitest": { - "configFile": "vitest.config.ts", - "related": false - }, - "coverageAnalysis": "perTest", - "ignoreStatic": true, - "incremental": true, - "incrementalFile": "reports/stryker-incremental.json", - "plugins": ["@stryker-mutator/vitest-runner"], - "checkers": [], - "mutate": [ - "services/commands/fuzzyScore.ts", - "services/commands/palettePreferences.ts", - "services/commands/commandBuilder.ts", - "services/help/helpDocRetrieval.ts", - "services/plotBoardService.ts", - "services/promptLibrary.ts", - "services/deepLinkService.ts", - "services/ai/modelRecommendations.ts", - "services/ai/aiPolicy.ts", - "services/ai/aiRetry.ts", - "services/ai/fetchAdapter.ts", - "services/ai/routingLogger.ts", - "features/project/projectSelectors.ts", - "features/project/sectionRestoreHelpers.ts", - "features/settings/accessibilitySchema.ts", - "features/progressTracker/progressTrackerSlice.ts", - "features/featureFlags/featureFlagsSlice.ts", - "features/proForge/proForgeSlice.ts", - "services/copilot/heuristicEngine.ts", - "services/copilot/insightGenerator.ts", - "services/copilot/actionApplier.ts", - "services/copilot/copilotContextService.ts", - "services/ai/aiModeService.ts", - "services/ai/providers/openrouterProvider.ts", - "services/pluginRegistry.ts" - ], - "thresholds": { - "high": 85, - "low": 70, - "break": 75 - }, - "reporters": ["progress", "json", "html"], - "htmlReporter": { - "fileName": "reports/mutation/mutation.html" - }, - "jsonReporter": { - "fileName": "reports/mutation/mutation.json" - }, - "timeoutMS": 60000, - "timeoutFactor": 2.0, - "concurrency": 2, - "tempDirName": ".stryker-tmp", - "warnings": { - "slow": true - }, - "ignorePatterns": [ - "**/dist/**", - "**/node_modules/**", - "**/*.test.ts", - "**/*.spec.ts", - "**/playwright-report/**", - "**/storybook-static/**" - ] -} diff --git a/stryker.config.mjs b/stryker.config.mjs new file mode 100644 index 000000000..a24695911 --- /dev/null +++ b/stryker.config.mjs @@ -0,0 +1,44 @@ +import { mutationFiles } from './scripts/stryker-scope.mjs'; + +export default { + packageManager: 'pnpm', + testRunner: 'vitest', + vitest: { + configFile: 'vitest.config.ts', + related: false, + }, + coverageAnalysis: 'perTest', + ignoreStatic: true, + incremental: true, + incrementalFile: 'reports/stryker-incremental.json', + plugins: ['@stryker-mutator/vitest-runner'], + checkers: [], + mutate: mutationFiles, + thresholds: { + high: 85, + low: 70, + break: 75, + }, + reporters: ['progress', 'json', 'html'], + htmlReporter: { + fileName: 'reports/mutation/mutation.html', + }, + jsonReporter: { + fileName: 'reports/mutation/mutation.json', + }, + timeoutMS: 60000, + timeoutFactor: 2.0, + concurrency: 2, + tempDirName: '.stryker-tmp', + warnings: { + slow: true, + }, + ignorePatterns: [ + '**/dist/**', + '**/node_modules/**', + '**/*.test.ts', + '**/*.spec.ts', + '**/playwright-report/**', + '**/storybook-static/**', + ], +}; diff --git a/tests/unit/tooling/strykerAggregation.test.ts b/tests/unit/tooling/strykerAggregation.test.ts new file mode 100644 index 000000000..96ff48c3f --- /dev/null +++ b/tests/unit/tooling/strykerAggregation.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + aggregateStrykerReports, + formatSummary, +} from '../../../scripts/aggregate-stryker-reports.mjs'; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function createReportRoot() { + const root = mkdtempSync(join(process.cwd(), '.stryker-report-test-')); + temporaryRoots.push(root); + return root; +} + +function writeReport(root: string, moduleName: string, overrides: Record = {}) { + const metrics = { + killed: 8, + survived: 1, + timeout: 1, + noCoverage: 0, + runtimeErrors: 0, + compileErrors: 0, + totalDetected: 9, + totalUndetected: 1, + totalCovered: 10, + totalValid: 10, + totalInvalid: 0, + totalMutants: 10, + ...overrides, + }; + const reportDirectory = join(root, `stryker-report-${moduleName}`); + mkdirSync(reportDirectory, { recursive: true }); + writeFileSync( + join(reportDirectory, 'mutation.json'), + JSON.stringify({ + metrics: { ...metrics, mutationScore: (metrics.totalDetected / metrics.totalValid) * 100 }, + }), + ); +} + +describe('Stryker report aggregation', () => { + it('requires every authoritative scope module', async () => { + const root = createReportRoot(); + writeReport(root, 'services-commands'); + await expect( + import('../../../scripts/aggregate-stryker-reports.mjs').then(({ aggregateStrykerReports }) => + aggregateStrykerReports(root), + ), + ).rejects.toThrow('Missing required Stryker reports'); + }); + + it('preserves no-coverage and timeout metrics in the weighted summary', async () => { + const root = createReportRoot(); + const { mutationModules } = await import('../../../scripts/stryker-scope.mjs'); + for (const module of mutationModules) writeReport(root, module.name); + writeReport(root, 'services-commands', { + noCoverage: 2, + timeout: 3, + totalUndetected: 3, + totalCovered: 10, + totalValid: 12, + }); + const result = aggregateStrykerReports(root); + expect(result.totals.noCoverage).toBe(2); + expect(result.totals.timeout).toBe(10); + expect(formatSummary(result)).toContain('No Cov'); + expect(formatSummary(result)).toContain('Total'); + }); +}); diff --git a/tests/unit/tooling/strykerWorkflowPolicy.test.ts b/tests/unit/tooling/strykerWorkflowPolicy.test.ts new file mode 100644 index 000000000..0b86c5cf5 --- /dev/null +++ b/tests/unit/tooling/strykerWorkflowPolicy.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { mutationFiles, mutationModules } from '../../../scripts/stryker-scope.mjs'; +import config from '../../../stryker.config.mjs'; + +const workflowPath = fileURLToPath( + new URL('../../../.github/workflows/mutation.yml', import.meta.url), +); +const workflow = readFileSync(workflowPath, 'utf8'); + +describe('Stryker workflow policy', () => { + it('uses one explicit target source for config and the matrix', () => { + expect(config.mutate).toEqual(mutationFiles); + expect(mutationModules).toHaveLength(8); + expect(new Set(mutationFiles).size).toBe(25); + }); + + it('uses supported incremental plumbing and preserves shard identity', () => { + expect(workflow).toContain('--incrementalFile "$INCREMENTAL_FILE"'); + expect(workflow).not.toContain('STRYKER_INCREMENTAL_FILE='); + expect(workflow).toContain('merge-multiple: false'); + expect(workflow).toContain('if-no-files-found: error'); + expect(workflow).toContain('needs.stryker.result'); + expect(workflow).toContain('node scripts/aggregate-stryker-reports.mjs all-reports'); + }); +}); From 3fb74fd1ee340135c3f19471806de55dd7dbfeea Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:52:34 +0200 Subject: [PATCH 2/7] chore(ci): make Stryker scope and cadence explicit --- .github/workflows/mutation.yml | 19 ++++++---- AGENTS.md | 17 +++++---- CLAUDE.md | 6 ++- CONTRIBUTING.md | 17 ++++++++- docs/CI.md | 24 ++++++++++-- scripts/stryker-scope.mjs | 37 ++++++++++++++++--- stryker-scope.json | 8 ++++ .../tooling/strykerWorkflowPolicy.test.ts | 11 +++++- 8 files changed, 109 insertions(+), 30 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index e8fc34018..79a7ab024 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -1,9 +1,8 @@ # ============================================================ # WorldScript Studio – Mutation Testing (Stryker) — Parallel + Incremental # QNBS-v3: Matrix-parallel Stryker with per-module caches and fail-safe aggregation. -# Run manually via workflow_dispatch. Each matrix job targets one module -# to keep wall-clock time low (~5–15 min per job vs. 30–45 min single job). -# Incremental caching skips unchanged mutants on re-runs (50–80% speedup). +# Run manually via workflow_dispatch. Each matrix job targets one curated module; +# runtime and score remain measured outputs rather than undocumented promises. # ============================================================ name: 🧬 Mutation Tests (Stryker) @@ -19,7 +18,10 @@ on: options: - incremental - force - - force-all-modules + module: + description: 'Scope selector: all, tier-a, or one module name' + required: false + default: 'all' per_job_concurrency: description: 'Concurrency per job (default: 2)' required: false @@ -45,8 +47,9 @@ jobs: - id: scope shell: bash run: | - node scripts/stryker-scope.mjs - echo "matrix=$(node scripts/stryker-scope.mjs --matrix)" >> "$GITHUB_OUTPUT" + SELECTOR="${{ github.event.inputs.module || 'all' }}" + node scripts/stryker-scope.mjs --selector "$SELECTOR" + echo "matrix=$(node scripts/stryker-scope.mjs --matrix --selector "$SELECTOR")" >> "$GITHUB_OUTPUT" # ============================================ # PARALLEL MATRIX JOBS (one per module) @@ -84,7 +87,7 @@ jobs: run: | mkdir -p reports MODE="${{ github.event.inputs.mode || 'incremental' }}" - if [ "$MODE" = "force" ] || [ "$MODE" = "force-all-modules" ]; then + if [ "$MODE" = "force" ]; then echo "🧹 Force mode → clearing incremental cache for ${{ matrix.name }}" rm -f reports/stryker-incremental-${{ matrix.name }}.json elif [ -f "reports/stryker-incremental-${{ matrix.name }}.json" ]; then @@ -97,7 +100,7 @@ jobs: run: | MODE="${{ github.event.inputs.mode || 'incremental' }}" FORCE_FLAG="" - if [ "$MODE" = "force" ] || [ "$MODE" = "force-all-modules" ]; then + if [ "$MODE" = "force" ]; then FORCE_FLAG="--force" fi diff --git a/AGENTS.md b/AGENTS.md index c704368b8..6748f2442 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ WorldScript-Studio/ - `playwright.config.ts` — E2E projects: Chromium desktop + Pixel 5 mobile in CI; Firefox + optional mobile locally - `turbo.json` — task graph for `build`, `dev`, `lint`, `typecheck`, `test`, `mutation` - `pnpm-workspace.yaml` — workspace packages + pnpm v11 `allowBuilds` default-deny map -- `stryker.conf.json` — ~20 mutation targets (services + features), `break: 75` +- `stryker.config.mjs` + `stryker-scope.json` — 25 curated production targets across 8 risk-tiered modules, `break: 75` - `.lighthouserc.cjs` — accessibility `error` ≥ 0.95, CLS `error` ≤ 0.1, performance/SEO `warn` - `src-tauri/tauri.conf.json` / `Cargo.toml` — desktop window config, CSP, updater endpoints, rust-compute feature @@ -181,7 +181,9 @@ pnpm run test:e2e # Playwright E2E (CI=true required; CI-only by polic pnpm run test:e2e:ui # Playwright E2E UI mode (CI=true required) pnpm run test:e2e:deep # Deep E2E feature-flag matrix (CI=true required) pnpm run test:vrt # Visual regression (Chromium only) -pnpm run mutation # Stryker mutation testing (CI-only; manual workflow) +pnpm run mutation # Stryker incremental mutation testing (CI-only; manual workflow) +pnpm run mutation:force # Stryker force/no-cache audit (CI-only) +pnpm run mutation:report # Aggregate downloaded reports; fails on missing/invalid shards # Analysis / budgets pnpm run analyze # Rollup visualizer → dist/bundle-analysis.html @@ -317,11 +319,12 @@ hooks are not installed. ### Mutation Testing (Stryker) -- Config: `stryker.conf.json` -- Targets: ~20 files across `services/`, `features/`, `services/copilot/`, `services/ai/providers/` -- `ignoreStatic: true` drops runtime from ~90 min to ~10 min. -- `break: 75` — score below 75 fails the mutation job. -- **Removed from PR/CI pipeline** (2026-06-02) — now runs only via manual `.github/workflows/mutation.yml` (`workflow_dispatch`). +- Config: `stryker.config.mjs`, with the authoritative target/risk registry in `stryker-scope.json`. +- Scope: 25 production files across 8 modules; Tier A is pure/domain/policy logic, Tier B is bounded adapter/orchestration logic. Generated, type-only, test, and presentation-only glue stays out of scope. +- Incremental mode uses Stryker's supported `--incrementalFile` option and one cache file per module. `mutation:force` intentionally bypasses that cache for release, security, or major-refactor audits. +- The manual workflow supports `module: all`, `tier-a`, or one module name. Matrix artifacts retain module identity; aggregation fails if any expected shard/report is missing or invalid and exposes killed, survived, timeout, no-coverage, and error counts separately. +- `break: 75`, `low: 70`, and `high: 85` remain the current operational thresholds; they are not a claimed measured baseline until a trusted force run records one. Do not change them to make a run green. +- **Removed from PR/CI pipeline** (2026-06-02) — mutation runs only via manual `.github/workflows/mutation.yml` (`workflow_dispatch`). Routine local validation must never run broad Stryker; cloud CI owns incremental, force, and report aggregation. ### Storybook diff --git a/CLAUDE.md b/CLAUDE.md index 44cd1bf2c..5447f7b3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,9 @@ pnpm run bench # Vitest perf benchmarks (tests/bench) — baseline gate pnpm run content:guard # Validate community templates for secrets / eval payloads pnpm run i18n:check # Locale key parity + bundle rebuild (runs in CI quality job) pnpm run i18n:bundle # Rebuild public/locales//bundle.json from source JSON -pnpm run mutation # Stryker mutation — CI-ONLY; trigger via: gh workflow run mutation.yml +pnpm run mutation # Stryker incremental mutation — CI-ONLY; trigger mutation.yml +pnpm run mutation:force # Stryker force/no-cache audit — CI-ONLY +pnpm run mutation:report # Aggregate downloaded module reports; fails on missing shards pnpm run test:e2e # Playwright E2E tests (CI=true required; CI-only) pnpm run test:e2e:deep # Deep coverage suite — feature-flag matrix + error paths (CI-only; non-blocking) pnpm run test:storybook # Storybook test-runner (CI; needs Storybook running or built) @@ -48,7 +50,7 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b **CI-cloud-first workflow (constrained local hardware only):** On low-end hardware, run only `ci:prepush` locally before pushing. Coverage, E2E, Lighthouse, and Stryker are CI-gate jobs. After each push, update README.md badges and AUDIT.md quality-gate line with CI-reported numbers. Local CI simulation: `act pull_request --job quality` (Docker + `act`; see `infra/low-end-ci/DAILY-DRIVER.md`). **CI audit & housekeeping policy (ALL CI runs must be fully green):** -- After every commit, monitor ALL CI jobs: security (OSV + CodeQL), quality (Biome + tsc + Vitest), build, e2e, lighthouse, deploy, mutation, storybook. +- After every commit, monitor all jobs for the active workflow. Stryker is a separate manual workflow, not a routine PR check; monitor it when explicitly dispatched for an incremental, Tier-A, module, or force audit. - **CodeQL scanning**: Check `https://github.com/qnbs/WorldScript-Studio/security/code-scanning` after every push. Fix the root cause — do not just suppress alerts. - **Token-Permissions**: All GitHub Actions workflows must set top-level `permissions: contents: read`; write permissions belong at the job level, never top-level. - **OSV vulnerabilities**: Run `pnpm audit` or check the security CI job. Add `pnpm.overrides` with pinned exact versions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0dae43dc7..251f1e28a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -286,12 +286,25 @@ Tests live in `tests/e2e/`. Playwright tests verify core user flows: ### Mutation testing (Stryker) -Targets service files (see [`stryker.conf.json`](stryker.conf.json)): `codexService`, `dbMigration`, `fuzzyScore`, `palettePreferences`, `commandBuilder`, `hybridFallback`, `providerFactory`, `helpDocRetrieval`, `listenerMiddleware`. HTML report: `reports/mutation/`. `thresholds.break` is `60` — CI fails if mutation score falls below this value. +Targets are defined once in [`stryker-scope.json`](stryker-scope.json) and consumed by +[`stryker.config.mjs`](stryker.config.mjs): 25 production files across 8 risk-tiered modules. +The manual workflow can run `all`, `tier-a`, or one module. Reports are kept under +`reports/mutation/` and uploaded with module identity; the aggregate fails on missing or invalid +shards and shows killed, survived, timeout, no-coverage, and error counts. Current operational +thresholds are `break: 75`, `low: 70`, and `high: 85`; they are not a measured baseline until a +trusted force run establishes one. ```bash -pnpm run mutation +pnpm run mutation # incremental; CI-only +pnpm run mutation:force # no-cache force audit; CI-only +pnpm run mutation:report # aggregate downloaded reports ``` +Do not run broad Stryker locally on constrained hardware. Use targeted tests locally and dispatch +the workflow for PR-related Tier-A/module diagnostics, recurring incremental checks, or release / +security force audits. Treat equivalent mutants as a documented survivor class; never hide +NoCoverage or timeout results and never change thresholds merely to make CI green. + ### Storybook ```bash diff --git a/docs/CI.md b/docs/CI.md index 5028727b5..ef6a2fb59 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -205,9 +205,25 @@ gh workflow run mutation.yml ``` `mutation.yml` is `workflow_dispatch`-only — it never runs automatically on push or PR. This means -the mutation score is **not continuously tracked**; it's a point-in-time snapshot whenever someone -runs it manually. [`stryker.conf.json`](../stryker.conf.json) still defines the thresholds -(`break: 75`, `high: 85`, `low: 70`) that would apply if it ran. +the mutation score is **not continuously tracked**; it is a point-in-time snapshot whenever someone +runs it manually. [`stryker.config.mjs`](../stryker.config.mjs) consumes the single authoritative +scope in [`stryker-scope.json`](../stryker-scope.json): 25 production targets across 8 modules. +Each module is marked Tier A (pure/domain/policy logic) or Tier B (bounded adapter/orchestration +logic), and the workflow accepts `all`, `tier-a`, or one module name for a bounded diagnostic run. + +Incremental runs pass Stryker's supported `--incrementalFile` option and cache one file per module; +the cache is not inferred from an arbitrary environment variable. `force` removes the selected +module cache before running, so it is the explicit no-cache mode for release, security, and major +refactor audits. Reports are uploaded under unique `stryker-report-` artifact directories; +the aggregate job requires a successful matrix and fails loudly on any missing, malformed, or +incomplete expected report. The summary keeps killed, survived, timeout, no-coverage, and error +counts visible instead of treating timeout/no-coverage as equivalent evidence. + +The current operational thresholds are `break: 75`, `high: 85`, and `low: 70`. They are preserved +until a trusted force run establishes measured module/global baselines; they must not be changed to +make a run green. The installed TypeScript checker is intentionally not used: Stryker's Vitest +runner and the repository's tsgo gate provide the supported compile/type signal, while enabling the +unused checker would add cost without an accepted compatibility baseline. **Re-integration criterion:** bring it back into the `quality` job (or a separate required check) once a manual run demonstrates the flakiness is resolved — concretely, three consecutive manual @@ -374,7 +390,7 @@ act pull_request -j quality -s CODECOV_TOKEN="$CODECOV_TOKEN" -W .github/workflo | `tests/e2e/collaboration.spec.ts` | Collaboration panel security warning banner pre-connect | | `services/commands/` | Command registry backing the palette (fuzzy search — regression-sensitive if E2E targets palette copy) | | `hooks/useGlobalKeyboardShortcuts.ts` | Global shortcut listener — keep in sync with **Settings → Shortcuts** defaults | -| `stryker.conf.json` | Mutation testing targets + thresholds (`break: 75`, `high: 85`, `low: 70`; 40 mutate targets as of v1.19.0) | +| `stryker.config.mjs` / `stryker-scope.json` | Curated 25-file mutation scope across 8 risk-tiered modules; current thresholds are `break: 75`, `high: 85`, `low: 70` | --- diff --git a/scripts/stryker-scope.mjs b/scripts/stryker-scope.mjs index 6e3056491..8142f4ccb 100644 --- a/scripts/stryker-scope.mjs +++ b/scripts/stryker-scope.mjs @@ -15,8 +15,13 @@ function validateScope(scopeDefinition) { const moduleNames = new Set(); const mutationFiles = new Set(); for (const module of scopeDefinition.modules) { - if (!module || typeof module.name !== 'string' || !Array.isArray(module.mutate)) { - throw new Error('Every Stryker scope module needs a name and mutate array.'); + if ( + !module || + typeof module.name !== 'string' || + !['A', 'B'].includes(module.riskTier) || + !Array.isArray(module.mutate) + ) { + throw new Error('Every Stryker scope module needs a name, riskTier (A/B), and mutate array.'); } if (moduleNames.has(module.name)) throw new Error(`Duplicate Stryker module: ${module.name}`); moduleNames.add(module.name); @@ -37,20 +42,40 @@ function validateScope(scopeDefinition) { const validatedScope = validateScope(scope); export const mutationFiles = [...validatedScope.mutationFiles]; -export const mutationModules = scope.modules.map(({ name, mutate }) => ({ +export const mutationModules = scope.modules.map(({ name, riskTier, mutate }) => ({ name, + riskTier, mutate: mutate.join(','), })); +export function selectMutationModules(selector = 'all') { + if (selector === 'all') return mutationModules; + if (selector === 'tier-a') return mutationModules.filter(({ riskTier }) => riskTier === 'A'); + const selected = mutationModules.filter(({ name }) => name === selector); + if (selected.length === 0) { + throw new Error( + `Unknown Stryker scope selector "${selector}". Use all, tier-a, or one of: ${mutationModules + .map(({ name }) => name) + .join(', ')}`, + ); + } + return selected; +} + // QNBS-v3: Generate the CI matrix from the same target definition used by Stryker itself. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const selectorIndex = process.argv.indexOf('--selector'); + const selector = selectorIndex === -1 ? 'all' : process.argv[selectorIndex + 1]; + const selectedModules = selectMutationModules(selector); if (process.argv.includes('--matrix')) { - process.stdout.write(`${JSON.stringify(mutationModules)}\n`); + process.stdout.write(`${JSON.stringify(selectedModules)}\n`); } else if (process.argv.includes('--files')) { - process.stdout.write(`${mutationFiles.join(',')}\n`); + process.stdout.write( + `${selectedModules.flatMap(({ mutate }) => mutate.split(',')).join(',')}\n`, + ); } else { process.stdout.write( - `Validated ${mutationFiles.length} Stryker targets across ${mutationModules.length} modules.\n`, + `Validated ${selectedModules.reduce((count, { mutate }) => count + mutate.split(',').length, 0)} Stryker targets across ${selectedModules.length} modules.\n`, ); } } diff --git a/stryker-scope.json b/stryker-scope.json index 8e21bfa81..27bd89228 100644 --- a/stryker-scope.json +++ b/stryker-scope.json @@ -3,6 +3,7 @@ "modules": [ { "name": "services-commands", + "riskTier": "A", "mutate": [ "services/commands/fuzzyScore.ts", "services/commands/palettePreferences.ts", @@ -11,6 +12,7 @@ }, { "name": "services-core", + "riskTier": "B", "mutate": [ "services/help/helpDocRetrieval.ts", "services/plotBoardService.ts", @@ -20,6 +22,7 @@ }, { "name": "services-ai-core", + "riskTier": "A", "mutate": [ "services/ai/modelRecommendations.ts", "services/ai/aiPolicy.ts", @@ -31,10 +34,12 @@ }, { "name": "services-ai-providers", + "riskTier": "B", "mutate": ["services/ai/providers/openrouterProvider.ts"] }, { "name": "features-project", + "riskTier": "A", "mutate": [ "features/project/projectSelectors.ts", "features/project/sectionRestoreHelpers.ts" @@ -42,6 +47,7 @@ }, { "name": "features-misc", + "riskTier": "B", "mutate": [ "features/settings/accessibilitySchema.ts", "features/progressTracker/progressTrackerSlice.ts", @@ -51,6 +57,7 @@ }, { "name": "copilot", + "riskTier": "A", "mutate": [ "services/copilot/heuristicEngine.ts", "services/copilot/insightGenerator.ts", @@ -60,6 +67,7 @@ }, { "name": "services-plugin", + "riskTier": "B", "mutate": ["services/pluginRegistry.ts"] } ] diff --git a/tests/unit/tooling/strykerWorkflowPolicy.test.ts b/tests/unit/tooling/strykerWorkflowPolicy.test.ts index 0b86c5cf5..782c06054 100644 --- a/tests/unit/tooling/strykerWorkflowPolicy.test.ts +++ b/tests/unit/tooling/strykerWorkflowPolicy.test.ts @@ -2,7 +2,11 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { mutationFiles, mutationModules } from '../../../scripts/stryker-scope.mjs'; +import { + mutationFiles, + mutationModules, + selectMutationModules, +} from '../../../scripts/stryker-scope.mjs'; import config from '../../../stryker.config.mjs'; const workflowPath = fileURLToPath( @@ -15,6 +19,9 @@ describe('Stryker workflow policy', () => { expect(config.mutate).toEqual(mutationFiles); expect(mutationModules).toHaveLength(8); expect(new Set(mutationFiles).size).toBe(25); + expect(mutationModules.every(({ riskTier }) => ['A', 'B'].includes(riskTier))).toBe(true); + expect(selectMutationModules('tier-a').every(({ riskTier }) => riskTier === 'A')).toBe(true); + expect(selectMutationModules('services-commands')).toHaveLength(1); }); it('uses supported incremental plumbing and preserves shard identity', () => { @@ -24,5 +31,7 @@ describe('Stryker workflow policy', () => { expect(workflow).toContain('if-no-files-found: error'); expect(workflow).toContain('needs.stryker.result'); expect(workflow).toContain('node scripts/aggregate-stryker-reports.mjs all-reports'); + expect(workflow).toContain('--selector "$SELECTOR"'); + expect(workflow).not.toContain('force-all-modules'); }); }); From 2d5dbeb31f4861b8538b3a4078c44c81007acac7 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:57:50 +0200 Subject: [PATCH 3/7] fix(ci): type Stryker tooling contracts --- scripts/aggregate-stryker-reports.d.mts | 30 +++++++++++++++++++++++++ scripts/stryker-scope.d.mts | 11 +++++++++ stryker.config.d.mts | 6 +++++ 3 files changed, 47 insertions(+) create mode 100644 scripts/aggregate-stryker-reports.d.mts create mode 100644 scripts/stryker-scope.d.mts create mode 100644 stryker.config.d.mts diff --git a/scripts/aggregate-stryker-reports.d.mts b/scripts/aggregate-stryker-reports.d.mts new file mode 100644 index 000000000..7ace789c3 --- /dev/null +++ b/scripts/aggregate-stryker-reports.d.mts @@ -0,0 +1,30 @@ +export interface StrykerMetrics { + killed: number; + survived: number; + timeout: number; + noCoverage: number; + runtimeErrors: number; + compileErrors: number; + totalDetected: number; + totalUndetected: number; + totalCovered: number; + totalValid: number; + totalInvalid: number; + totalMutants: number; +} + +export interface StrykerModuleReport { + name: string; + metrics: StrykerMetrics; + mutationScore: number; +} + +export interface StrykerAggregateResult { + reports: StrykerModuleReport[]; + totals: StrykerMetrics; + mutationScore: number; +} + +export function readStrykerReports(rootDirectory: string): StrykerModuleReport[]; +export function aggregateStrykerReports(rootDirectory: string): StrykerAggregateResult; +export function formatSummary(result: StrykerAggregateResult): string; diff --git a/scripts/stryker-scope.d.mts b/scripts/stryker-scope.d.mts new file mode 100644 index 000000000..6e09ce282 --- /dev/null +++ b/scripts/stryker-scope.d.mts @@ -0,0 +1,11 @@ +export type StrykerRiskTier = 'A' | 'B'; + +export interface StrykerMutationModule { + name: string; + riskTier: StrykerRiskTier; + mutate: string; +} + +export const mutationFiles: string[]; +export const mutationModules: StrykerMutationModule[]; +export function selectMutationModules(selector?: string): StrykerMutationModule[]; diff --git a/stryker.config.d.mts b/stryker.config.d.mts new file mode 100644 index 000000000..2a0f0be14 --- /dev/null +++ b/stryker.config.d.mts @@ -0,0 +1,6 @@ +const config: { + mutate: string[]; + thresholds: { high: number; low: number; break: number }; +}; + +export default config; From b847818299bf5c36cf373dcc10d09e33197186b2 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:04:35 +0200 Subject: [PATCH 4/7] fix(ci): materialize Stryker matrix includes --- scripts/stryker-scope.mjs | 2 +- tests/unit/tooling/strykerWorkflowPolicy.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/stryker-scope.mjs b/scripts/stryker-scope.mjs index 8142f4ccb..af7b105b7 100644 --- a/scripts/stryker-scope.mjs +++ b/scripts/stryker-scope.mjs @@ -68,7 +68,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me const selector = selectorIndex === -1 ? 'all' : process.argv[selectorIndex + 1]; const selectedModules = selectMutationModules(selector); if (process.argv.includes('--matrix')) { - process.stdout.write(`${JSON.stringify(selectedModules)}\n`); + process.stdout.write(`${JSON.stringify({ include: selectedModules })}\n`); } else if (process.argv.includes('--files')) { process.stdout.write( `${selectedModules.flatMap(({ mutate }) => mutate.split(',')).join(',')}\n`, diff --git a/tests/unit/tooling/strykerWorkflowPolicy.test.ts b/tests/unit/tooling/strykerWorkflowPolicy.test.ts index 782c06054..44575f0dc 100644 --- a/tests/unit/tooling/strykerWorkflowPolicy.test.ts +++ b/tests/unit/tooling/strykerWorkflowPolicy.test.ts @@ -1,4 +1,5 @@ // @vitest-environment node +import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -13,6 +14,9 @@ const workflowPath = fileURLToPath( new URL('../../../.github/workflows/mutation.yml', import.meta.url), ); const workflow = readFileSync(workflowPath, 'utf8'); +const scopeScriptPath = fileURLToPath( + new URL('../../../scripts/stryker-scope.mjs', import.meta.url), +); describe('Stryker workflow policy', () => { it('uses one explicit target source for config and the matrix', () => { @@ -22,6 +26,10 @@ describe('Stryker workflow policy', () => { expect(mutationModules.every(({ riskTier }) => ['A', 'B'].includes(riskTier))).toBe(true); expect(selectMutationModules('tier-a').every(({ riskTier }) => riskTier === 'A')).toBe(true); expect(selectMutationModules('services-commands')).toHaveLength(1); + const matrix = JSON.parse( + execFileSync(process.execPath, [scopeScriptPath, '--matrix'], { encoding: 'utf8' }), + ); + expect(matrix.include).toEqual(mutationModules); }); it('uses supported incremental plumbing and preserves shard identity', () => { From b59951df79f245bf4e8314272ceb79fb59c7c94d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:45:11 +0200 Subject: [PATCH 5/7] fix(ci): harden Stryker review contracts --- .github/workflows/mutation.yml | 11 ++-- package.json | 1 + pnpm-lock.yaml | 3 ++ scripts/aggregate-stryker-reports.d.mts | 11 +++- scripts/aggregate-stryker-reports.mjs | 44 ++++++++++++---- scripts/assert-ci-only.mjs | 1 + scripts/stryker-scope.d.mts | 1 + scripts/stryker-scope.mjs | 32 +++++++----- stryker.config.d.mts | 8 +-- stryker.config.mjs | 1 + tests/unit/tooling/strykerAggregation.test.ts | 51 ++++++++++++++++++- .../tooling/strykerWorkflowPolicy.test.ts | 6 ++- 12 files changed, 135 insertions(+), 35 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 79a7ab024..644ace181 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -46,10 +46,13 @@ jobs: persist-credentials: false - id: scope shell: bash + env: + SELECTOR: ${{ github.event.inputs.module || 'all' }} run: | - SELECTOR="${{ github.event.inputs.module || 'all' }}" node scripts/stryker-scope.mjs --selector "$SELECTOR" - echo "matrix=$(node scripts/stryker-scope.mjs --matrix --selector "$SELECTOR")" >> "$GITHUB_OUTPUT" + printf 'matrix=%s\n' \ + "$(node scripts/stryker-scope.mjs --matrix --selector "$SELECTOR")" \ + >> "$GITHUB_OUTPUT" # ============================================ # PARALLEL MATRIX JOBS (one per module) @@ -159,12 +162,14 @@ jobs: merge-multiple: false - name: Build combined summary + env: + SELECTOR: ${{ github.event.inputs.module || 'all' }} run: | if [ "${{ needs.stryker.result }}" != "success" ]; then echo "Stryker matrix did not complete successfully: ${{ needs.stryker.result }}" >&2 exit 1 fi - node scripts/aggregate-stryker-reports.mjs all-reports | tee -a "$GITHUB_STEP_SUMMARY" + node scripts/aggregate-stryker-reports.mjs all-reports --selector "$SELECTOR" | tee -a "$GITHUB_STEP_SUMMARY" echo "> Mode: \`${{ github.event.inputs.mode || 'incremental' }}\` | Concurrency: \`${{ github.event.inputs.per_job_concurrency || '2' }}\`" >> "$GITHUB_STEP_SUMMARY" echo "> Threshold: break ≥ 75 | low ≥ 70 | high ≥ 85" >> "$GITHUB_STEP_SUMMARY" echo "> Individual HTML/JSON reports are available as artifacts." >> "$GITHUB_STEP_SUMMARY" diff --git a/package.json b/package.json index 884c11c71..c5c5a1f9c 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "@storybook/react": "^10.5.7", "@storybook/react-vite": "^10.5.7", "@storybook/test-runner": "^0.24.4", + "@stryker-mutator/api": "^9.6.1", "@stryker-mutator/core": "^9.2.0", "@stryker-mutator/vitest-runner": "^9.2.0", "@tailwindcss/vite": "^4.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd6dc3469..e128f07f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,9 @@ importers: '@storybook/test-runner': specifier: ^0.24.4 version: 0.24.4(@swc/helpers@0.5.21)(@types/node@25.9.2)(storybook@10.5.7(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8)) + '@stryker-mutator/api': + specifier: ^9.6.1 + version: 9.6.1 '@stryker-mutator/core': specifier: ^9.2.0 version: 9.6.1(@types/node@25.9.2) diff --git a/scripts/aggregate-stryker-reports.d.mts b/scripts/aggregate-stryker-reports.d.mts index 7ace789c3..2e81079a6 100644 --- a/scripts/aggregate-stryker-reports.d.mts +++ b/scripts/aggregate-stryker-reports.d.mts @@ -1,3 +1,4 @@ +// QNBS-v3: Keep report and aggregate types aligned with the fail-closed validator. export interface StrykerMetrics { killed: number; survived: number; @@ -25,6 +26,12 @@ export interface StrykerAggregateResult { mutationScore: number; } -export function readStrykerReports(rootDirectory: string): StrykerModuleReport[]; -export function aggregateStrykerReports(rootDirectory: string): StrykerAggregateResult; +export function readStrykerReports( + rootDirectory: string, + selectedModules?: import('./stryker-scope.mjs').StrykerMutationModule[], +): StrykerModuleReport[]; +export function aggregateStrykerReports( + rootDirectory: string, + selectedModules?: import('./stryker-scope.mjs').StrykerMutationModule[], +): StrykerAggregateResult; export function formatSummary(result: StrykerAggregateResult): string; diff --git a/scripts/aggregate-stryker-reports.mjs b/scripts/aggregate-stryker-reports.mjs index 305d94eaf..dac858530 100644 --- a/scripts/aggregate-stryker-reports.mjs +++ b/scripts/aggregate-stryker-reports.mjs @@ -1,8 +1,9 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { mutationModules } from './stryker-scope.mjs'; +import { mutationModules, selectMutationModules } from './stryker-scope.mjs'; +// QNBS-v3: Reject partial or inconsistent shard reports before aggregation can false-green. const metricNames = [ 'killed', 'survived', @@ -26,11 +27,29 @@ function readMetric(report, metricName) { return value; } -export function readStrykerReports(rootDirectory) { +function validateMetricRelationships(metrics, reportPath) { + const relationships = [ + ['totalDetected', metrics.killed + metrics.timeout], + ['totalUndetected', metrics.survived + metrics.noCoverage], + ['totalCovered', metrics.totalDetected + metrics.survived], + ['totalValid', metrics.totalDetected + metrics.totalUndetected], + ['totalInvalid', metrics.runtimeErrors + metrics.compileErrors], + ['totalMutants', metrics.totalValid + metrics.totalInvalid], + ]; + for (const [name, expected] of relationships) { + if (metrics[name] !== expected) { + throw new Error( + `Stryker report has inconsistent metrics.${name}: expected ${expected}, got ${metrics[name]} (${reportPath}).`, + ); + } + } +} + +export function readStrykerReports(rootDirectory, selectedModules = mutationModules) { const reports = []; const missing = []; - for (const module of mutationModules) { + for (const module of selectedModules) { const reportPath = path.join(rootDirectory, `stryker-report-${module.name}`, 'mutation.json'); if (!existsSync(reportPath)) { missing.push(`${module.name}/mutation.json`); @@ -46,10 +65,8 @@ export function readStrykerReports(rootDirectory) { throw new Error(`Stryker report has no metrics object: ${reportPath}`); } const metrics = Object.fromEntries(metricNames.map((name) => [name, readMetric(report, name)])); - const score = report.metrics.mutationScore; - if (typeof score !== 'number' || !Number.isFinite(score)) { - throw new Error(`Stryker report has no finite mutation score: ${reportPath}`); - } + validateMetricRelationships(metrics, reportPath); + const score = metrics.totalValid > 0 ? (metrics.totalDetected / metrics.totalValid) * 100 : 0; reports.push({ name: module.name, metrics, mutationScore: score }); } @@ -59,14 +76,14 @@ export function readStrykerReports(rootDirectory) { return reports; } -export function aggregateStrykerReports(rootDirectory) { - const reports = readStrykerReports(rootDirectory); +export function aggregateStrykerReports(rootDirectory, selectedModules = mutationModules) { + const reports = readStrykerReports(rootDirectory, selectedModules); const totals = Object.fromEntries(metricNames.map((name) => [name, 0])); for (const report of reports) { for (const name of metricNames) totals[name] += report.metrics[name]; } const mutationScore = - totals.totalValid > 0 ? (totals.totalDetected / totals.totalValid) * 100 : 100; + totals.totalValid > 0 ? (totals.totalDetected / totals.totalValid) * 100 : 0; return { reports, totals, mutationScore }; } @@ -94,8 +111,13 @@ export function formatSummary(result) { if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const rootDirectory = process.argv[2] ?? 'all-reports'; + const selectorIndex = process.argv.indexOf('--selector'); + const selectorValue = selectorIndex === -1 ? 'all' : process.argv[selectorIndex + 1]; try { - const result = aggregateStrykerReports(rootDirectory); + if (!selectorValue || selectorValue.startsWith('--')) { + throw new Error('--selector requires a value: all, tier-a, or a module name.'); + } + const result = aggregateStrykerReports(rootDirectory, selectMutationModules(selectorValue)); const summary = formatSummary(result); process.stdout.write(summary); } catch (error) { diff --git a/scripts/assert-ci-only.mjs b/scripts/assert-ci-only.mjs index 315eaac23..343439224 100644 --- a/scripts/assert-ci-only.mjs +++ b/scripts/assert-ci-only.mjs @@ -1,5 +1,6 @@ const task = process.argv.slice(2).join(' ') || 'This task'; +// QNBS-v3: Keep expensive mutation runs off the constrained development workstation. if (process.env.CI !== 'true') { console.error( `${task} is CI-only on this constrained workstation. Use: gh workflow run mutation.yml`, diff --git a/scripts/stryker-scope.d.mts b/scripts/stryker-scope.d.mts index 6e09ce282..46b51a895 100644 --- a/scripts/stryker-scope.d.mts +++ b/scripts/stryker-scope.d.mts @@ -1,5 +1,6 @@ export type StrykerRiskTier = 'A' | 'B'; +// QNBS-v3: Type the scope registry and selectors consumed by the CI matrix. export interface StrykerMutationModule { name: string; riskTier: StrykerRiskTier; diff --git a/scripts/stryker-scope.mjs b/scripts/stryker-scope.mjs index af7b105b7..553490504 100644 --- a/scripts/stryker-scope.mjs +++ b/scripts/stryker-scope.mjs @@ -65,17 +65,25 @@ export function selectMutationModules(selector = 'all') { // QNBS-v3: Generate the CI matrix from the same target definition used by Stryker itself. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const selectorIndex = process.argv.indexOf('--selector'); - const selector = selectorIndex === -1 ? 'all' : process.argv[selectorIndex + 1]; - const selectedModules = selectMutationModules(selector); - if (process.argv.includes('--matrix')) { - process.stdout.write(`${JSON.stringify({ include: selectedModules })}\n`); - } else if (process.argv.includes('--files')) { - process.stdout.write( - `${selectedModules.flatMap(({ mutate }) => mutate.split(',')).join(',')}\n`, - ); - } else { - process.stdout.write( - `Validated ${selectedModules.reduce((count, { mutate }) => count + mutate.split(',').length, 0)} Stryker targets across ${selectedModules.length} modules.\n`, - ); + try { + const selector = selectorIndex === -1 ? 'all' : process.argv[selectorIndex + 1]; + if (!selector || selector.startsWith('--')) { + throw new Error('--selector requires a value: all, tier-a, or a module name.'); + } + const selectedModules = selectMutationModules(selector); + if (process.argv.includes('--matrix')) { + process.stdout.write(`${JSON.stringify({ include: selectedModules })}\n`); + } else if (process.argv.includes('--files')) { + process.stdout.write( + `${selectedModules.flatMap(({ mutate }) => mutate.split(',')).join(',')}\n`, + ); + } else { + process.stdout.write( + `Validated ${selectedModules.reduce((count, { mutate }) => count + mutate.split(',').length, 0)} Stryker targets across ${selectedModules.length} modules.\n`, + ); + } + } catch (error) { + console.error(`Stryker scope validation failed: ${error.message}`); + process.exitCode = 1; } } diff --git a/stryker.config.d.mts b/stryker.config.d.mts index 2a0f0be14..978fa1aad 100644 --- a/stryker.config.d.mts +++ b/stryker.config.d.mts @@ -1,6 +1,6 @@ -const config: { - mutate: string[]; - thresholds: { high: number; low: number; break: number }; -}; +import type { PartialStrykerOptions } from '@stryker-mutator/api/core'; + +// QNBS-v3: Keep the declaration aligned with Stryker's maintained option contract. +declare const config: PartialStrykerOptions; export default config; diff --git a/stryker.config.mjs b/stryker.config.mjs index a24695911..624abd919 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -1,5 +1,6 @@ import { mutationFiles } from './scripts/stryker-scope.mjs'; +// QNBS-v3: Share the validated risk-based mutation configuration between local tooling and CI. export default { packageManager: 'pnpm', testRunner: 'vitest', diff --git a/tests/unit/tooling/strykerAggregation.test.ts b/tests/unit/tooling/strykerAggregation.test.ts index 96ff48c3f..fb4870c70 100644 --- a/tests/unit/tooling/strykerAggregation.test.ts +++ b/tests/unit/tooling/strykerAggregation.test.ts @@ -9,6 +9,7 @@ import { const temporaryRoots: string[] = []; +// QNBS-v3: Exercise fail-closed aggregation and preserve actionable mutation metrics. afterEach(() => { for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -61,11 +62,14 @@ describe('Stryker report aggregation', () => { const { mutationModules } = await import('../../../scripts/stryker-scope.mjs'); for (const module of mutationModules) writeReport(root, module.name); writeReport(root, 'services-commands', { + killed: 8, noCoverage: 2, timeout: 3, totalUndetected: 3, - totalCovered: 10, - totalValid: 12, + totalDetected: 11, + totalCovered: 12, + totalValid: 14, + totalMutants: 14, }); const result = aggregateStrykerReports(root); expect(result.totals.noCoverage).toBe(2); @@ -73,4 +77,47 @@ describe('Stryker report aggregation', () => { expect(formatSummary(result)).toContain('No Cov'); expect(formatSummary(result)).toContain('Total'); }); + + it('aggregates only the validated all, tier-a, or named module selection', async () => { + const root = createReportRoot(); + const { mutationModules, selectMutationModules } = await import( + '../../../scripts/stryker-scope.mjs' + ); + for (const module of mutationModules) writeReport(root, module.name); + + for (const selector of ['all', 'tier-a', 'services-commands']) { + const selectedModules = selectMutationModules(selector); + const result = aggregateStrykerReports(root, selectedModules); + expect(result.reports).toHaveLength(selectedModules.length); + } + }); + + it('rejects inconsistent Stryker metrics instead of trusting report scores', async () => { + const root = createReportRoot(); + const { mutationModules } = await import('../../../scripts/stryker-scope.mjs'); + for (const module of mutationModules) writeReport(root, module.name); + writeReport(root, 'services-commands', { totalDetected: 8 }); + + expect(() => aggregateStrykerReports(root)).toThrow('inconsistent metrics.totalDetected'); + }); + + it('uses a zero score when a report contains no valid mutants', async () => { + const root = createReportRoot(); + const { selectMutationModules } = await import('../../../scripts/stryker-scope.mjs'); + const selectedModules = selectMutationModules('services-commands'); + writeReport(root, 'services-commands', { + killed: 0, + survived: 0, + timeout: 0, + noCoverage: 0, + totalDetected: 0, + totalUndetected: 0, + totalCovered: 0, + totalValid: 0, + totalInvalid: 0, + totalMutants: 0, + }); + + expect(aggregateStrykerReports(root, selectedModules).mutationScore).toBe(0); + }); }); diff --git a/tests/unit/tooling/strykerWorkflowPolicy.test.ts b/tests/unit/tooling/strykerWorkflowPolicy.test.ts index 44575f0dc..6ee0ee6d4 100644 --- a/tests/unit/tooling/strykerWorkflowPolicy.test.ts +++ b/tests/unit/tooling/strykerWorkflowPolicy.test.ts @@ -18,6 +18,7 @@ const scopeScriptPath = fileURLToPath( new URL('../../../scripts/stryker-scope.mjs', import.meta.url), ); +// QNBS-v3: Lock workflow/config invariants so mutation plumbing cannot silently drift. describe('Stryker workflow policy', () => { it('uses one explicit target source for config and the matrix', () => { expect(config.mutate).toEqual(mutationFiles); @@ -38,8 +39,11 @@ describe('Stryker workflow policy', () => { expect(workflow).toContain('merge-multiple: false'); expect(workflow).toContain('if-no-files-found: error'); expect(workflow).toContain('needs.stryker.result'); - expect(workflow).toContain('node scripts/aggregate-stryker-reports.mjs all-reports'); + expect(workflow).toContain( + 'node scripts/aggregate-stryker-reports.mjs all-reports --selector "$SELECTOR"', + ); expect(workflow).toContain('--selector "$SELECTOR"'); + expect(workflow).toContain('SELECTOR: $' + "{{ github.event.inputs.module || 'all' }}"); expect(workflow).not.toContain('force-all-modules'); }); }); From da50b521b72dde40c9d2f6696f5029f770566e05 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:12:32 +0200 Subject: [PATCH 6/7] fix(ci): make Stryker execution measurable --- .github/workflows/mutation.yml | 6 +- AGENTS.md | 6 +- CONTRIBUTING.md | 7 +- docs/CI.md | 10 +- package.json | 4 +- pnpm-lock.yaml | 232 ++++-------------- scripts/aggregate-stryker-reports.d.mts | 2 + scripts/aggregate-stryker-reports.mjs | 72 +++++- stryker.config.mjs | 2 +- tests/unit/tooling/strykerAggregation.test.ts | 47 ++++ .../tooling/strykerWorkflowPolicy.test.ts | 2 + 11 files changed, 178 insertions(+), 212 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 644ace181..ad593e756 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -166,10 +166,12 @@ jobs: SELECTOR: ${{ github.event.inputs.module || 'all' }} run: | if [ "${{ needs.stryker.result }}" != "success" ]; then - echo "Stryker matrix did not complete successfully: ${{ needs.stryker.result }}" >&2 - exit 1 + echo "Stryker matrix completed with: ${{ needs.stryker.result }}; the summary remains informational and this job will fail closed." >&2 fi node scripts/aggregate-stryker-reports.mjs all-reports --selector "$SELECTOR" | tee -a "$GITHUB_STEP_SUMMARY" + if [ "${{ needs.stryker.result }}" != "success" ]; then + exit 1 + fi echo "> Mode: \`${{ github.event.inputs.mode || 'incremental' }}\` | Concurrency: \`${{ github.event.inputs.per_job_concurrency || '2' }}\`" >> "$GITHUB_STEP_SUMMARY" echo "> Threshold: break ≥ 75 | low ≥ 70 | high ≥ 85" >> "$GITHUB_STEP_SUMMARY" echo "> Individual HTML/JSON reports are available as artifacts." >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 6748f2442..2bec90c4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ The app supports a multi-provider AI stack (Gemini, OpenAI, Claude, Grok, OpenRo | Type checker | `tsgo` (TypeScript Go port) via `tsconfig.tsgo.json` with 4 checkers (`pnpm run typecheck`) | | Styling | Tailwind CSS `^4.3.1` via `@tailwindcss/vite` + semantic CSS custom properties (`index.css`) | | State | Redux Toolkit `^2.12.0` + `redux-undo` (project slice only); Zustand `^5.0.14` for transient UI (`app/transientUiStore.ts`) | -| Testing | Vitest `^4.1.10` (jsdom, `maxWorkers: 1`), Playwright `^1.61.1` (E2E, CI-only), Stryker `^9.2.0` (mutation, manual workflow only) | +| Testing | Vitest `^4.1.10` (jsdom, `maxWorkers: 1`), Playwright `^1.61.1` (E2E, CI-only), Stryker `9.6.1` (mutation, manual workflow only) | | Lint/Format | Biome `^2.5.4` (`biome.json`) — single toolchain for JS/TS/CSS | | AI | Multi-provider: Google Gemini (`@google/genai`), OpenAI, Anthropic Claude, Grok, OpenRouter, Ollama, WebLLM, ONNX Runtime Web, Transformers.js | | Voice | Web Speech API (fallback); WASM engines: Whisper.cpp (STT), Kokoro (TTS), Silero VAD; gated by `featureFlags.enableVoiceWasm` | @@ -321,8 +321,8 @@ hooks are not installed. - Config: `stryker.config.mjs`, with the authoritative target/risk registry in `stryker-scope.json`. - Scope: 25 production files across 8 modules; Tier A is pure/domain/policy logic, Tier B is bounded adapter/orchestration logic. Generated, type-only, test, and presentation-only glue stays out of scope. -- Incremental mode uses Stryker's supported `--incrementalFile` option and one cache file per module. `mutation:force` intentionally bypasses that cache for release, security, or major-refactor audits. -- The manual workflow supports `module: all`, `tier-a`, or one module name. Matrix artifacts retain module identity; aggregation fails if any expected shard/report is missing or invalid and exposes killed, survived, timeout, no-coverage, and error counts separately. +- Incremental mode uses Stryker's supported `--incrementalFile` option and one cache file per module; `vitest.related: true` keeps each mutant on related tests instead of rerunning the full suite. `mutation:force` intentionally bypasses that cache for release, security, or major-refactor audits. +- The manual workflow supports `module: all`, `tier-a`, or one module name. Matrix artifacts retain module identity; aggregation derives canonical metrics from mutant statuses, fails if any expected shard/report is missing or invalid, and exposes killed, survived, timeout, no-coverage, ignored, pending, and error counts separately. - `break: 75`, `low: 70`, and `high: 85` remain the current operational thresholds; they are not a claimed measured baseline until a trusted force run records one. Do not change them to make a run green. - **Removed from PR/CI pipeline** (2026-06-02) — mutation runs only via manual `.github/workflows/mutation.yml` (`workflow_dispatch`). Routine local validation must never run broad Stryker; cloud CI owns incremental, force, and report aggregation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 251f1e28a..7dbf66c63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -288,9 +288,10 @@ Tests live in `tests/e2e/`. Playwright tests verify core user flows: Targets are defined once in [`stryker-scope.json`](stryker-scope.json) and consumed by [`stryker.config.mjs`](stryker.config.mjs): 25 production files across 8 risk-tiered modules. -The manual workflow can run `all`, `tier-a`, or one module. Reports are kept under -`reports/mutation/` and uploaded with module identity; the aggregate fails on missing or invalid -shards and shows killed, survived, timeout, no-coverage, and error counts. Current operational +The manual workflow can run `all`, `tier-a`, or one module. Related-test selection avoids rerunning +the full Vitest suite for every mutant. Reports are kept under `reports/mutation/` and uploaded +with module identity; the aggregate derives metrics from mutant statuses and fails on missing or +invalid shards while showing killed, survived, timeout, no-coverage, ignored, pending, and error counts. Current operational thresholds are `break: 75`, `low: 70`, and `high: 85`; they are not a measured baseline until a trusted force run establishes one. diff --git a/docs/CI.md b/docs/CI.md index ef6a2fb59..708122d0c 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -215,9 +215,11 @@ Incremental runs pass Stryker's supported `--incrementalFile` option and cache o the cache is not inferred from an arbitrary environment variable. `force` removes the selected module cache before running, so it is the explicit no-cache mode for release, security, and major refactor audits. Reports are uploaded under unique `stryker-report-` artifact directories; -the aggregate job requires a successful matrix and fails loudly on any missing, malformed, or -incomplete expected report. The summary keeps killed, survived, timeout, no-coverage, and error -counts visible instead of treating timeout/no-coverage as equivalent evidence. +the aggregate job derives metrics from real mutant statuses under `files`, keeps the summary visible +when a shard fails, and fails loudly on any missing, malformed, or incomplete expected report. The +summary keeps killed, survived, timeout, no-coverage, ignored, pending, and error counts visible +instead of treating timeout/no-coverage as equivalent evidence. `vitest.related: true` is enabled +so each mutant uses related tests instead of rerunning the full suite. The current operational thresholds are `break: 75`, `high: 85`, and `low: 70`. They are preserved until a trusted force run establishes measured module/global baselines; they must not be changed to @@ -228,7 +230,7 @@ unused checker would add cost without an accepted compatibility baseline. **Re-integration criterion:** bring it back into the `quality` job (or a separate required check) once a manual run demonstrates the flakiness is resolved — concretely, three consecutive manual `workflow_dispatch` runs on `main` completing without a spurious failure or timeout, at the current -40-target mutate scope. Until then, treat a manual run's score as informational only, not a gate. +25-target mutate scope. Until then, treat a manual run's score as informational only, not a gate. --- diff --git a/package.json b/package.json index c5c5a1f9c..50016dab1 100644 --- a/package.json +++ b/package.json @@ -166,8 +166,8 @@ "@storybook/react-vite": "^10.5.7", "@storybook/test-runner": "^0.24.4", "@stryker-mutator/api": "^9.6.1", - "@stryker-mutator/core": "^9.2.0", - "@stryker-mutator/vitest-runner": "^9.2.0", + "@stryker-mutator/core": "9.6.1", + "@stryker-mutator/vitest-runner": "9.6.1", "@tailwindcss/vite": "^4.3.3", "@tauri-apps/cli": "^2.11.4", "@testing-library/dom": "^10.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e128f07f9..1445c0fac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,10 +236,10 @@ importers: specifier: ^9.6.1 version: 9.6.1 '@stryker-mutator/core': - specifier: ^9.2.0 + specifier: 9.6.1 version: 9.6.1(@types/node@25.9.2) '@stryker-mutator/vitest-runner': - specifier: ^9.2.0 + specifier: 9.6.1 version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.2))(vitest@4.1.10) '@tailwindcss/vite': specifier: ^4.3.3 @@ -497,10 +497,6 @@ packages: resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} - engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.29.7': resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} @@ -509,12 +505,6 @@ packages: resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': '>=7.29.6 <8' - '@babel/helper-create-class-features-plugin@7.29.7': resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} engines: {node: '>=6.9.0'} @@ -536,10 +526,6 @@ packages: resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.28.5': - resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} - engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.29.7': resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} @@ -554,18 +540,10 @@ packages: peerDependencies: '@babel/core': '>=7.29.6 <8' - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} - engines: {node: '>=6.9.0'} - '@babel/helper-optimise-call-expression@7.29.7': resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.29.7': resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} @@ -576,22 +554,12 @@ packages: peerDependencies: '@babel/core': '>=7.29.6 <8' - '@babel/helper-replace-supers@7.28.6': - resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': '>=7.29.6 <8' - '@babel/helper-replace-supers@7.29.7': resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': '>=7.29.6 <8' - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} - engines: {node: '>=6.9.0'} - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} engines: {node: '>=6.9.0'} @@ -612,10 +580,6 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -860,12 +824,6 @@ packages: peerDependencies: '@babel/core': '>=7.29.6 <8' - '@babel/plugin-transform-destructuring@7.28.5': - resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': '>=7.29.6 <8' - '@babel/plugin-transform-destructuring@7.29.7': resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} engines: {node: '>=6.9.0'} @@ -896,12 +854,6 @@ packages: peerDependencies: '@babel/core': '>=7.29.6 <8' - '@babel/plugin-transform-explicit-resource-management@7.28.6': - resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': '>=7.29.6 <8' - '@babel/plugin-transform-explicit-resource-management@7.29.7': resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} engines: {node: '>=6.9.0'} @@ -962,12 +914,6 @@ packages: peerDependencies: '@babel/core': '>=7.29.6 <8' - '@babel/plugin-transform-modules-commonjs@7.28.6': - resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': '>=7.29.6 <8' - '@babel/plugin-transform-modules-commonjs@7.29.7': resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} engines: {node: '>=6.9.0'} @@ -5998,10 +5944,6 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -6847,10 +6789,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - side-channel@1.1.1: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} @@ -8045,7 +7983,7 @@ snapshots: '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -8059,10 +7997,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.27.3': - dependencies: - '@babel/types': 7.29.8 - '@babel/helper-annotate-as-pure@7.29.7': dependencies: '@babel/types': 7.29.8 @@ -8075,19 +8009,6 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.8 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8121,13 +8042,6 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.28.5': - dependencies: - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-member-expression-to-functions@7.29.7': dependencies: '@babel/traverse': 7.29.8 @@ -8138,7 +8052,7 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -8151,16 +8065,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-optimise-call-expression@7.27.1': - dependencies: - '@babel/types': 7.29.8 - '@babel/helper-optimise-call-expression@7.29.7': dependencies: '@babel/types': 7.29.8 - '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-plugin-utils@7.29.7': {} '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': @@ -8172,15 +8080,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8190,13 +8089,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - dependencies: - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: '@babel/traverse': 7.29.8 @@ -8212,8 +8104,6 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helper-wrap-function@7.29.7': @@ -8287,8 +8177,8 @@ snapshots: '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -8300,27 +8190,27 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': dependencies: @@ -8330,7 +8220,7 @@ snapshots: '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': dependencies: @@ -8340,62 +8230,62 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': dependencies: @@ -8470,14 +8360,6 @@ snapshots: '@babel/helper-plugin-utils': 7.29.7 '@babel/template': 7.29.7 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8508,14 +8390,6 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8579,14 +8453,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8740,10 +8606,10 @@ snapshots: '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -8858,10 +8724,10 @@ snapshots: '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -8879,15 +8745,15 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/types': 7.29.7 '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.7 debug: 4.4.3 @@ -10337,7 +10203,7 @@ snapshots: execa: 9.6.1 json-rpc-2.0: 1.7.1 lodash.groupby: 4.6.0 - minimatch: 10.2.5 + minimatch: 10.2.6 mutation-server-protocol: 0.4.1 mutation-testing-elements: 3.7.3 mutation-testing-metrics: 3.7.3 @@ -10345,7 +10211,7 @@ snapshots: npm-run-path: 6.0.0 progress: 2.0.3 rxjs: 7.8.2 - semver: 7.7.4 + semver: 7.8.5 source-map: 0.7.6 tree-kill: 1.2.2 tslib: 2.8.1 @@ -10358,10 +10224,10 @@ snapshots: '@stryker-mutator/instrumenter@9.6.1': dependencies: '@babel/core': 7.29.7 - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.3 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/util': 9.6.1 @@ -10379,7 +10245,7 @@ snapshots: '@stryker-mutator/api': 9.6.1 '@stryker-mutator/core': 9.6.1(@types/node@25.9.2) '@stryker-mutator/util': 9.6.1 - semver: 7.7.4 + semver: 7.8.5 tslib: 2.8.1 vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.2.1(@types/node@25.9.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -11192,7 +11058,7 @@ snapshots: babel-plugin-istanbul@7.0.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 6.0.3 @@ -13290,7 +13156,7 @@ snapshots: jest-snapshot@30.4.1: dependencies: '@babel/core': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) '@babel/types': 7.29.0 @@ -13811,10 +13677,6 @@ snapshots: minimalistic-assert@1.0.1: {} - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.9 - minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -14350,7 +14212,7 @@ snapshots: qs@6.15.2: dependencies: - side-channel: 1.1.0 + side-channel: 1.1.1 queue-microtask@1.2.3: {} @@ -14827,14 +14689,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - side-channel@1.1.1: dependencies: es-errors: 1.3.0 diff --git a/scripts/aggregate-stryker-reports.d.mts b/scripts/aggregate-stryker-reports.d.mts index 2e81079a6..2d4ccbfb5 100644 --- a/scripts/aggregate-stryker-reports.d.mts +++ b/scripts/aggregate-stryker-reports.d.mts @@ -1,5 +1,7 @@ // QNBS-v3: Keep report and aggregate types aligned with the fail-closed validator. export interface StrykerMetrics { + pending: number; + ignored: number; killed: number; survived: number; timeout: number; diff --git a/scripts/aggregate-stryker-reports.mjs b/scripts/aggregate-stryker-reports.mjs index dac858530..f3b49aa7f 100644 --- a/scripts/aggregate-stryker-reports.mjs +++ b/scripts/aggregate-stryker-reports.mjs @@ -5,6 +5,8 @@ import { mutationModules, selectMutationModules } from './stryker-scope.mjs'; // QNBS-v3: Reject partial or inconsistent shard reports before aggregation can false-green. const metricNames = [ + 'pending', + 'ignored', 'killed', 'survived', 'timeout', @@ -21,12 +23,66 @@ const metricNames = [ function readMetric(report, metricName) { const value = report.metrics?.[metricName]; + if (value === undefined && ['pending', 'ignored'].includes(metricName)) return 0; if (!Number.isInteger(value) || value < 0) { throw new Error(`Stryker report has invalid metrics.${metricName}.`); } return value; } +function deriveMetricsFromMutants(report, reportPath) { + if (!report.files || typeof report.files !== 'object') { + throw new Error(`Stryker report has neither metrics nor files: ${reportPath}`); + } + const statusCounts = Object.create(null); + const knownStatuses = new Set([ + 'CompileError', + 'Ignored', + 'Killed', + 'NoCoverage', + 'Pending', + 'RuntimeError', + 'Survived', + 'Timeout', + ]); + for (const file of Object.values(report.files)) { + if (!file || !Array.isArray(file.mutants)) { + throw new Error(`Stryker report has an invalid mutants list: ${reportPath}`); + } + for (const mutant of file.mutants) { + if (!mutant || typeof mutant.status !== 'string' || !knownStatuses.has(mutant.status)) { + throw new Error(`Stryker report has an unknown mutant status: ${reportPath}`); + } + statusCounts[mutant.status] = (statusCounts[mutant.status] ?? 0) + 1; + } + } + const metrics = Object.fromEntries([ + ['pending', statusCounts.Pending ?? 0], + ['ignored', statusCounts.Ignored ?? 0], + ['killed', statusCounts.Killed ?? 0], + ['survived', statusCounts.Survived ?? 0], + ['timeout', statusCounts.Timeout ?? 0], + ['noCoverage', statusCounts.NoCoverage ?? 0], + ['runtimeErrors', statusCounts.RuntimeError ?? 0], + ['compileErrors', statusCounts.CompileError ?? 0], + ]); + metrics.totalDetected = metrics.killed + metrics.timeout; + metrics.totalUndetected = metrics.survived + metrics.noCoverage; + metrics.totalCovered = metrics.totalDetected + metrics.survived; + metrics.totalValid = metrics.totalDetected + metrics.totalUndetected; + metrics.totalInvalid = metrics.runtimeErrors + metrics.compileErrors; + metrics.totalMutants = + metrics.totalValid + metrics.totalInvalid + metrics.ignored + metrics.pending; + return metrics; +} + +function readReportMetrics(report, reportPath) { + if (report.metrics && typeof report.metrics === 'object') { + return Object.fromEntries(metricNames.map((name) => [name, readMetric(report, name)])); + } + return deriveMetricsFromMutants(report, reportPath); +} + function validateMetricRelationships(metrics, reportPath) { const relationships = [ ['totalDetected', metrics.killed + metrics.timeout], @@ -34,7 +90,7 @@ function validateMetricRelationships(metrics, reportPath) { ['totalCovered', metrics.totalDetected + metrics.survived], ['totalValid', metrics.totalDetected + metrics.totalUndetected], ['totalInvalid', metrics.runtimeErrors + metrics.compileErrors], - ['totalMutants', metrics.totalValid + metrics.totalInvalid], + ['totalMutants', metrics.totalValid + metrics.totalInvalid + metrics.ignored + metrics.pending], ]; for (const [name, expected] of relationships) { if (metrics[name] !== expected) { @@ -61,10 +117,10 @@ export function readStrykerReports(rootDirectory, selectedModules = mutationModu } catch (error) { throw new Error(`Cannot parse ${reportPath}: ${error.message}`); } - if (!report || typeof report.metrics !== 'object') { - throw new Error(`Stryker report has no metrics object: ${reportPath}`); + if (!report || typeof report !== 'object') { + throw new Error(`Stryker report is not an object: ${reportPath}`); } - const metrics = Object.fromEntries(metricNames.map((name) => [name, readMetric(report, name)])); + const metrics = readReportMetrics(report, reportPath); validateMetricRelationships(metrics, reportPath); const score = metrics.totalValid > 0 ? (metrics.totalDetected / metrics.totalValid) * 100 : 0; reports.push({ name: module.name, metrics, mutationScore: score }); @@ -91,18 +147,18 @@ export function formatSummary(result) { const lines = [ '## 🧬 Stryker Mutation Results', '', - '| Module | Score | Killed | Survived | Timeout | No Cov | Errors |', - '|--------|------:|-------:|---------:|--------:|-------:|-------:|', + '| Module | Score | Killed | Survived | Timeout | No Cov | Ignored | Pending | Errors |', + '|--------|------:|-------:|---------:|--------:|-------:|--------:|--------:|-------:|', ]; for (const report of result.reports) { const { metrics } = report; const errors = metrics.runtimeErrors + metrics.compileErrors; lines.push( - `| ${report.name} | ${report.mutationScore.toFixed(1)}% | ${metrics.killed} | ${metrics.survived} | ${metrics.timeout} | ${metrics.noCoverage} | ${errors} |`, + `| ${report.name} | ${report.mutationScore.toFixed(1)}% | ${metrics.killed} | ${metrics.survived} | ${metrics.timeout} | ${metrics.noCoverage} | ${metrics.ignored} | ${metrics.pending} | ${errors} |`, ); } lines.push( - `| **Total** | **${result.mutationScore.toFixed(1)}%** | **${result.totals.killed}** | **${result.totals.survived}** | **${result.totals.timeout}** | **${result.totals.noCoverage}** | **${result.totals.runtimeErrors + result.totals.compileErrors}** |`, + `| **Total** | **${result.mutationScore.toFixed(1)}%** | **${result.totals.killed}** | **${result.totals.survived}** | **${result.totals.timeout}** | **${result.totals.noCoverage}** | **${result.totals.ignored}** | **${result.totals.pending}** | **${result.totals.runtimeErrors + result.totals.compileErrors}** |`, '', '> Every expected matrix shard produced a valid report; missing or invalid reports fail this job.', ); diff --git a/stryker.config.mjs b/stryker.config.mjs index 624abd919..58c6a73fe 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -6,7 +6,7 @@ export default { testRunner: 'vitest', vitest: { configFile: 'vitest.config.ts', - related: false, + related: true, }, coverageAnalysis: 'perTest', ignoreStatic: true, diff --git a/tests/unit/tooling/strykerAggregation.test.ts b/tests/unit/tooling/strykerAggregation.test.ts index fb4870c70..74bfa79a1 100644 --- a/tests/unit/tooling/strykerAggregation.test.ts +++ b/tests/unit/tooling/strykerAggregation.test.ts @@ -22,6 +22,8 @@ function createReportRoot() { function writeReport(root: string, moduleName: string, overrides: Record = {}) { const metrics = { + pending: 0, + ignored: 0, killed: 8, survived: 1, timeout: 1, @@ -120,4 +122,49 @@ describe('Stryker report aggregation', () => { expect(aggregateStrykerReports(root, selectedModules).mutationScore).toBe(0); }); + + it('derives canonical metrics from Stryker mutant statuses', async () => { + const root = createReportRoot(); + const reportDirectory = join(root, 'stryker-report-services-commands'); + mkdirSync(reportDirectory, { recursive: true }); + writeFileSync( + join(reportDirectory, 'mutation.json'), + JSON.stringify({ + files: { + 'services/commands/example.ts': { + mutants: [ + { status: 'Killed' }, + { status: 'Timeout' }, + { status: 'Survived' }, + { status: 'NoCoverage' }, + { status: 'RuntimeError' }, + { status: 'CompileError' }, + { status: 'Ignored' }, + { status: 'Pending' }, + ], + }, + }, + }), + ); + const { selectMutationModules } = await import('../../../scripts/stryker-scope.mjs'); + const result = aggregateStrykerReports(root, selectMutationModules('services-commands')); + + const report = result.reports[0]; + expect(report).toBeDefined(); + if (!report) throw new Error('Expected one services-commands report.'); + expect(report.metrics).toMatchObject({ + killed: 1, + timeout: 1, + survived: 1, + noCoverage: 1, + runtimeErrors: 1, + compileErrors: 1, + ignored: 1, + pending: 1, + totalValid: 4, + totalInvalid: 2, + totalMutants: 8, + }); + expect(result.mutationScore).toBe(50); + }); }); diff --git a/tests/unit/tooling/strykerWorkflowPolicy.test.ts b/tests/unit/tooling/strykerWorkflowPolicy.test.ts index 6ee0ee6d4..dcca9ec02 100644 --- a/tests/unit/tooling/strykerWorkflowPolicy.test.ts +++ b/tests/unit/tooling/strykerWorkflowPolicy.test.ts @@ -22,6 +22,7 @@ const scopeScriptPath = fileURLToPath( describe('Stryker workflow policy', () => { it('uses one explicit target source for config and the matrix', () => { expect(config.mutate).toEqual(mutationFiles); + expect(config['vitest']).toEqual(expect.objectContaining({ related: true })); expect(mutationModules).toHaveLength(8); expect(new Set(mutationFiles).size).toBe(25); expect(mutationModules.every(({ riskTier }) => ['A', 'B'].includes(riskTier))).toBe(true); @@ -39,6 +40,7 @@ describe('Stryker workflow policy', () => { expect(workflow).toContain('merge-multiple: false'); expect(workflow).toContain('if-no-files-found: error'); expect(workflow).toContain('needs.stryker.result'); + expect(workflow).toContain('the summary remains informational and this job will fail closed'); expect(workflow).toContain( 'node scripts/aggregate-stryker-reports.mjs all-reports --selector "$SELECTOR"', ); From 012985bfe1506a3a9ddfadf0cc14ebcd128e7ed8 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:34:22 +0200 Subject: [PATCH 7/7] fix(ci): preserve Stryker aggregation failures --- .github/workflows/mutation.yml | 1 + package.json | 2 +- pnpm-lock.yaml | 2 +- tests/unit/tooling/strykerWorkflowPolicy.test.ts | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index ad593e756..e4d304b51 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -165,6 +165,7 @@ jobs: env: SELECTOR: ${{ github.event.inputs.module || 'all' }} run: | + set -o pipefail if [ "${{ needs.stryker.result }}" != "success" ]; then echo "Stryker matrix completed with: ${{ needs.stryker.result }}; the summary remains informational and this job will fail closed." >&2 fi diff --git a/package.json b/package.json index 50016dab1..4e9c9103d 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,7 @@ "@storybook/react": "^10.5.7", "@storybook/react-vite": "^10.5.7", "@storybook/test-runner": "^0.24.4", - "@stryker-mutator/api": "^9.6.1", + "@stryker-mutator/api": "9.6.1", "@stryker-mutator/core": "9.6.1", "@stryker-mutator/vitest-runner": "9.6.1", "@tailwindcss/vite": "^4.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1445c0fac..5785b812b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,7 +233,7 @@ importers: specifier: ^0.24.4 version: 0.24.4(@swc/helpers@0.5.21)(@types/node@25.9.2)(storybook@10.5.7(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8)) '@stryker-mutator/api': - specifier: ^9.6.1 + specifier: 9.6.1 version: 9.6.1 '@stryker-mutator/core': specifier: 9.6.1 diff --git a/tests/unit/tooling/strykerWorkflowPolicy.test.ts b/tests/unit/tooling/strykerWorkflowPolicy.test.ts index dcca9ec02..b373b99dd 100644 --- a/tests/unit/tooling/strykerWorkflowPolicy.test.ts +++ b/tests/unit/tooling/strykerWorkflowPolicy.test.ts @@ -44,6 +44,7 @@ describe('Stryker workflow policy', () => { expect(workflow).toContain( 'node scripts/aggregate-stryker-reports.mjs all-reports --selector "$SELECTOR"', ); + expect(workflow).toContain('set -o pipefail'); expect(workflow).toContain('--selector "$SELECTOR"'); expect(workflow).toContain('SELECTOR: $' + "{{ github.event.inputs.module || 'all' }}"); expect(workflow).not.toContain('force-all-modules');