Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 53 additions & 13 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,46 +87,86 @@ 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 \
--incremental \
--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()
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2924_keys-0EA5E9" alt="i18n 19 locales — 2924 keys">
<img src="https://img.shields.io/badge/Tests-6910%2B_%2F_568_files-22C55E" alt="6910+ tests / 568 files">
<img src="https://img.shields.io/badge/Tests-6912%2B_%2F_568_files-22C55E" alt="6912+ tests / 568 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 27 additions & 3 deletions scripts/aggregate-stryker-reports.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions scripts/stryker-scope.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
mutationFiles: Set<string>;
};
13 changes: 11 additions & 2 deletions scripts/stryker-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Expand All @@ -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}`);
Expand All @@ -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') {
Expand Down
6 changes: 6 additions & 0 deletions stryker-scope.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
},
{
Expand Down
2 changes: 0 additions & 2 deletions stryker.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ export default {
ignorePatterns: [
'**/dist/**',
'**/node_modules/**',
'**/*.test.ts',
'**/*.spec.ts',
'**/playwright-report/**',
'**/storybook-static/**',
],
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/tooling/strykerAggregation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ function writeReport(root: string, moduleName: string, overrides: Record<string,
);
}

function writeFlatReport(root: string) {
writeFileSync(
join(root, 'mutation.json'),
JSON.stringify({
metrics: {
pending: 0,
ignored: 0,
killed: 8,
survived: 1,
timeout: 1,
noCoverage: 0,
runtimeErrors: 0,
compileErrors: 0,
totalDetected: 9,
totalUndetected: 1,
totalCovered: 10,
totalValid: 10,
totalInvalid: 0,
totalMutants: 10,
},
}),
);
}

describe('Stryker report aggregation', () => {
it('requires every authoritative scope module', async () => {
const root = createReportRoot();
Expand Down Expand Up @@ -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');
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/tooling/strykerWorkflowPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
mutationFiles,
mutationModules,
selectMutationModules,
validateScope,
} from '../../../scripts/stryker-scope.mjs';
import config from '../../../stryker.config.mjs';

Expand All @@ -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' }),
);
Expand All @@ -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');
Expand All @@ -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');
});
});
Loading