diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml
index e4d304b51..49e5ae094 100644
--- a/.github/workflows/mutation.yml
+++ b/.github/workflows/mutation.yml
@@ -87,35 +87,76 @@ jobs:
stryker-incremental-${{ matrix.name }}-
- name: Prepare incremental file
+ env:
+ MATRIX_NAME: ${{ matrix.name }}
+ MODE_INPUT: ${{ github.event.inputs.mode || 'incremental' }}
run: |
+ if [[ ! "$MATRIX_NAME" =~ ^[a-z0-9-]+$ ]]; then
+ echo "Invalid Stryker module name: $MATRIX_NAME" >&2
+ exit 1
+ fi
+ INCREMENTAL_FILE="reports/stryker-incremental-${MATRIX_NAME}.json"
mkdir -p reports
- MODE="${{ github.event.inputs.mode || 'incremental' }}"
+ MODE="$MODE_INPUT"
+ if [[ "$MODE" != "incremental" && "$MODE" != "force" ]]; then
+ echo "Invalid Stryker mode: $MODE" >&2
+ exit 1
+ fi
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
- echo "โป๏ธ Using existing incremental cache for ${{ matrix.name }}"
+ echo "๐งน Force mode โ clearing incremental cache for $MATRIX_NAME"
+ rm -f "$INCREMENTAL_FILE"
+ elif [ -f "$INCREMENTAL_FILE" ]; then
+ echo "โป๏ธ Using existing incremental cache for $MATRIX_NAME"
else
echo "๐ No incremental cache found โ will create new one"
fi
- name: Run Stryker (${{ matrix.name }})
+ env:
+ MATRIX_NAME: ${{ matrix.name }}
+ MATRIX_MUTATE: ${{ matrix.mutate }}
+ MATRIX_TEST_FILES: ${{ join(matrix.testFiles, ',') }}
+ MODE_INPUT: ${{ github.event.inputs.mode || 'incremental' }}
+ CONCURRENCY_INPUT: ${{ github.event.inputs.per_job_concurrency || '2' }}
+ STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }}
run: |
- MODE="${{ github.event.inputs.mode || 'incremental' }}"
+ if [[ ! "$MATRIX_NAME" =~ ^[a-z0-9-]+$ ]]; then
+ echo "Invalid Stryker module name: $MATRIX_NAME" >&2
+ exit 1
+ fi
+ MODE="$MODE_INPUT"
+ if [[ "$MODE" != "incremental" && "$MODE" != "force" ]]; then
+ echo "Invalid Stryker mode: $MODE" >&2
+ exit 1
+ fi
FORCE_FLAG=""
if [ "$MODE" = "force" ]; then
FORCE_FLAG="--force"
fi
- CONCURRENCY="${{ github.event.inputs.per_job_concurrency || '2' }}"
+ CONCURRENCY="$CONCURRENCY_INPUT"
+ if [[ ! "$CONCURRENCY" =~ ^[0-9]+%?$ ]]; then
+ echo "Invalid Stryker concurrency: $CONCURRENCY" >&2
+ exit 1
+ fi
- echo "โถ๏ธ Module: ${{ matrix.name }}"
- echo " Pattern: ${{ matrix.mutate }}"
+ echo "โถ๏ธ Module: $MATRIX_NAME"
+ echo " Pattern: $MATRIX_MUTATE"
+ TEST_FILES="$MATRIX_TEST_FILES"
+ if [ -n "$TEST_FILES" ]; then
+ echo " Test files: $TEST_FILES"
+ else
+ echo " Test files: Vitest related selection"
+ fi
echo " Concurrency: $CONCURRENCY"
echo " Mode: $MODE"
# QNBS-v3: pass the supported CLI option; arbitrary env vars are not Stryker config.
- INCREMENTAL_FILE="reports/stryker-incremental-${{ matrix.name }}.json"
+ INCREMENTAL_FILE="reports/stryker-incremental-${MATRIX_NAME}.json"
+ TEST_FILE_ARGS=()
+ if [ -n "$TEST_FILES" ]; then
+ TEST_FILE_ARGS+=(--testFiles "$TEST_FILES")
+ fi
pnpm exec stryker run \
stryker.config.mjs \
@@ -123,10 +164,9 @@ jobs:
--incrementalFile "$INCREMENTAL_FILE" \
$FORCE_FLAG \
--concurrency "$CONCURRENCY" \
- --mutate "${{ matrix.mutate }}" \
+ --mutate "$MATRIX_MUTATE" \
+ "${TEST_FILE_ARGS[@]}" \
--reporters progress,json,html
- env:
- STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }}
- name: Upload Stryker report
if: always()
diff --git a/README.md b/README.md
index d0016fea4..898583b30 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -511,7 +511,7 @@ The Settings โ AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2924 keys ร 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (6910+ tests / 568 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (6912+ tests / 568 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -549,7 +549,7 @@ WorldScript-Studio/
โ โโโ sw.js # PWA Service Worker
โ โโโ manifest.json # PWA Web App Manifest v3
โโโ tests/
-โ โโโ unit/ # Vitest unit tests (6910+ tests, 568 files) โ count spans tests/, components/, packages/*/tests/, not just this folder
+โ โโโ unit/ # Vitest unit tests (6912+ tests, 568 files) โ count spans tests/, components/, packages/*/tests/, not just this folder
โ โ โโโ ai/ # aiSmallModules, aiCoreFallbackPaths
โ โ โโโ settings/ # WebLlmPanel, AiSections
โ โโโ e2e/ # Playwright specs + helpers.ts
@@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard โ SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **6910+ unit tests** across **568 test files** โ CI is authoritative for pass/fail
+- **6912+ unit tests** across **568 test files** โ CI is authoritative for pass/fail
- Coverage thresholds: lines โฅ 80 ยท branches โฅ 66 ยท functions โฅ 72 ยท statements โฅ 78 โ enforced in CI (see Codecov badge for live metrics)
- i18n: **2924 keys ร 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/scripts/aggregate-stryker-reports.mjs b/scripts/aggregate-stryker-reports.mjs
index f3b49aa7f..08a9bf363 100644
--- a/scripts/aggregate-stryker-reports.mjs
+++ b/scripts/aggregate-stryker-reports.mjs
@@ -1,4 +1,4 @@
-import { existsSync, readFileSync } from 'node:fs';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { mutationModules, selectMutationModules } from './stryker-scope.mjs';
@@ -101,13 +101,37 @@ function validateMetricRelationships(metrics, reportPath) {
}
}
+function findReportPath(rootDirectory, moduleName, selectedModuleCount) {
+ const preferredPath = path.join(rootDirectory, `stryker-report-${moduleName}`, 'mutation.json');
+ if (existsSync(preferredPath)) return preferredPath;
+ if (!existsSync(rootDirectory)) return undefined;
+
+ const candidates = [];
+ const visit = (directory, depth) => {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isDirectory() && depth < 5) {
+ visit(entryPath, depth + 1);
+ continue;
+ }
+ if (!entry.isFile() || entry.name !== 'mutation.json') continue;
+ const relativeSegments = path.relative(rootDirectory, entryPath).split(path.sep);
+ const preservesModuleIdentity = relativeSegments.includes(`stryker-report-${moduleName}`);
+ if (preservesModuleIdentity || selectedModuleCount === 1) candidates.push(entryPath);
+ }
+ };
+ visit(rootDirectory, 0);
+
+ return candidates.length === 1 ? candidates[0] : undefined;
+}
+
export function readStrykerReports(rootDirectory, selectedModules = mutationModules) {
const reports = [];
const missing = [];
for (const module of selectedModules) {
- const reportPath = path.join(rootDirectory, `stryker-report-${module.name}`, 'mutation.json');
- if (!existsSync(reportPath)) {
+ const reportPath = findReportPath(rootDirectory, module.name, selectedModules.length);
+ if (!reportPath) {
missing.push(`${module.name}/mutation.json`);
continue;
}
diff --git a/scripts/stryker-scope.d.mts b/scripts/stryker-scope.d.mts
index 46b51a895..47d8b097c 100644
--- a/scripts/stryker-scope.d.mts
+++ b/scripts/stryker-scope.d.mts
@@ -5,8 +5,13 @@ export interface StrykerMutationModule {
name: string;
riskTier: StrykerRiskTier;
mutate: string;
+ testFiles: string[];
}
export const mutationFiles: string[];
export const mutationModules: StrykerMutationModule[];
export function selectMutationModules(selector?: string): StrykerMutationModule[];
+export function validateScope(scopeDefinition: unknown): {
+ moduleNames: Set;
+ mutationFiles: Set;
+};
diff --git a/scripts/stryker-scope.mjs b/scripts/stryker-scope.mjs
index 553490504..8e211f2cc 100644
--- a/scripts/stryker-scope.mjs
+++ b/scripts/stryker-scope.mjs
@@ -7,7 +7,7 @@ const scopePath = path.join(repositoryRoot, 'stryker-scope.json');
export const scope = JSON.parse(readFileSync(scopePath, 'utf8'));
-function validateScope(scopeDefinition) {
+export function validateScope(scopeDefinition) {
if (!Array.isArray(scopeDefinition.modules) || scopeDefinition.modules.length === 0) {
throw new Error('Stryker scope must define at least one module.');
}
@@ -27,6 +27,9 @@ function validateScope(scopeDefinition) {
moduleNames.add(module.name);
if (module.mutate.length === 0)
throw new Error(`Stryker module has no targets: ${module.name}`);
+ if (module.testFiles !== undefined && !Array.isArray(module.testFiles)) {
+ throw new Error(`Stryker testFiles must be an array: ${module.name}`);
+ }
for (const file of module.mutate) {
if (typeof file !== 'string' || mutationFiles.has(file)) {
throw new Error(`Duplicate or invalid Stryker target: ${file}`);
@@ -36,16 +39,22 @@ function validateScope(scopeDefinition) {
}
mutationFiles.add(file);
}
+ for (const file of module.testFiles ?? []) {
+ if (typeof file !== 'string' || !existsSync(path.join(repositoryRoot, file))) {
+ throw new Error(`Stryker test file does not exist: ${file}`);
+ }
+ }
}
return { moduleNames, mutationFiles };
}
const validatedScope = validateScope(scope);
export const mutationFiles = [...validatedScope.mutationFiles];
-export const mutationModules = scope.modules.map(({ name, riskTier, mutate }) => ({
+export const mutationModules = scope.modules.map(({ name, riskTier, mutate, testFiles = [] }) => ({
name,
riskTier,
mutate: mutate.join(','),
+ testFiles,
}));
export function selectMutationModules(selector = 'all') {
diff --git a/stryker-scope.json b/stryker-scope.json
index 27bd89228..3cad8c23b 100644
--- a/stryker-scope.json
+++ b/stryker-scope.json
@@ -63,6 +63,12 @@
"services/copilot/insightGenerator.ts",
"services/copilot/actionApplier.ts",
"services/copilot/copilotContextService.ts"
+ ],
+ "testFiles": [
+ "tests/unit/copilot/heuristicEngine.test.ts",
+ "tests/unit/copilot/insightGenerator.test.ts",
+ "tests/unit/copilot/actionApplier.test.ts",
+ "tests/unit/copilot/copilotContextService.test.ts"
]
},
{
diff --git a/stryker.config.mjs b/stryker.config.mjs
index 58c6a73fe..84e817c4d 100644
--- a/stryker.config.mjs
+++ b/stryker.config.mjs
@@ -37,8 +37,6 @@ export default {
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
index 74bfa79a1..7fbb772a1 100644
--- a/tests/unit/tooling/strykerAggregation.test.ts
+++ b/tests/unit/tooling/strykerAggregation.test.ts
@@ -48,6 +48,30 @@ function writeReport(root: string, moduleName: string, overrides: Record {
it('requires every authoritative scope module', async () => {
const root = createReportRoot();
@@ -94,6 +118,17 @@ describe('Stryker report aggregation', () => {
}
});
+ it('accepts a flat report when exactly one module is selected', async () => {
+ const root = createReportRoot();
+ writeFlatReport(root);
+ const { selectMutationModules } = await import('../../../scripts/stryker-scope.mjs');
+
+ const result = aggregateStrykerReports(root, selectMutationModules('services-commands'));
+
+ expect(result.reports).toHaveLength(1);
+ expect(result.reports[0]?.name).toBe('services-commands');
+ });
+
it('rejects inconsistent Stryker metrics instead of trusting report scores', async () => {
const root = createReportRoot();
const { mutationModules } = await import('../../../scripts/stryker-scope.mjs');
diff --git a/tests/unit/tooling/strykerWorkflowPolicy.test.ts b/tests/unit/tooling/strykerWorkflowPolicy.test.ts
index b373b99dd..f710c0b56 100644
--- a/tests/unit/tooling/strykerWorkflowPolicy.test.ts
+++ b/tests/unit/tooling/strykerWorkflowPolicy.test.ts
@@ -7,6 +7,7 @@ import {
mutationFiles,
mutationModules,
selectMutationModules,
+ validateScope,
} from '../../../scripts/stryker-scope.mjs';
import config from '../../../stryker.config.mjs';
@@ -17,17 +18,26 @@ const workflow = readFileSync(workflowPath, 'utf8');
const scopeScriptPath = fileURLToPath(
new URL('../../../scripts/stryker-scope.mjs', import.meta.url),
);
+const githubExpression = (expression: string) => '$' + '{{ ' + expression + ' }}';
// 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);
expect(config['vitest']).toEqual(expect.objectContaining({ related: true }));
+ expect(config.ignorePatterns).not.toContain('**/*.test.ts');
+ expect(config.ignorePatterns).not.toContain('**/*.spec.ts');
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);
+ expect(selectMutationModules('copilot')[0]?.testFiles).toEqual([
+ 'tests/unit/copilot/heuristicEngine.test.ts',
+ 'tests/unit/copilot/insightGenerator.test.ts',
+ 'tests/unit/copilot/actionApplier.test.ts',
+ 'tests/unit/copilot/copilotContextService.test.ts',
+ ]);
const matrix = JSON.parse(
execFileSync(process.execPath, [scopeScriptPath, '--matrix'], { encoding: 'utf8' }),
);
@@ -36,6 +46,17 @@ describe('Stryker workflow policy', () => {
it('uses supported incremental plumbing and preserves shard identity', () => {
expect(workflow).toContain('--incrementalFile "$INCREMENTAL_FILE"');
+ expect(workflow).toContain('MATRIX_NAME: ' + githubExpression('matrix.name'));
+ expect(workflow).toContain('MATRIX_MUTATE: ' + githubExpression('matrix.mutate'));
+ expect(workflow).toContain(
+ 'MATRIX_TEST_FILES: ' + githubExpression("join(matrix.testFiles, ',')"),
+ );
+ expect(workflow).toContain('TEST_FILE_ARGS+=(--testFiles "$TEST_FILES")');
+ expect(workflow).toContain('"${' + 'TEST_FILE_ARGS[@]}"');
+ expect(workflow).not.toContain(
+ 'rm -f reports/stryker-incremental-' + githubExpression('matrix.name') + '.json',
+ );
+ expect(workflow).not.toContain('--mutate "' + githubExpression('matrix.mutate') + '"');
expect(workflow).not.toContain('STRYKER_INCREMENTAL_FILE=');
expect(workflow).toContain('merge-multiple: false');
expect(workflow).toContain('if-no-files-found: error');
@@ -49,4 +70,23 @@ describe('Stryker workflow policy', () => {
expect(workflow).toContain('SELECTOR: $' + "{{ github.event.inputs.module || 'all' }}");
expect(workflow).not.toContain('force-all-modules');
});
+
+ it('validates optional module test-file mappings fail closed', () => {
+ const validModule = {
+ name: 'test-module',
+ riskTier: 'A' as const,
+ mutate: ['services/commands/fuzzyScore.ts'],
+ };
+
+ expect(() => validateScope({ modules: [validModule] })).not.toThrow();
+ expect(() =>
+ validateScope({ modules: [{ ...validModule, testFiles: 'not-an-array' }] }),
+ ).toThrow('Stryker testFiles must be an array');
+ expect(() => validateScope({ modules: [{ ...validModule, testFiles: [42] }] })).toThrow(
+ 'Stryker test file does not exist',
+ );
+ expect(() =>
+ validateScope({ modules: [{ ...validModule, testFiles: ['tests/unit/missing.test.ts'] }] }),
+ ).toThrow('Stryker test file does not exist');
+ });
});