diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..1f43a4a0
--- /dev/null
+++ b/.github/CODE_OF_CONDUCT.md
@@ -0,0 +1,132 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or advances
+ of any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for
+moderation decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces (issues, pull
+requests, discussions, and any other project-hosted forum), and also applies
+when an individual is officially representing the community in public spaces.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported via **[GitHub Private Vulnerability Reporting](https://github.com/qnbs/WorldScript-Studio/security/advisories/new)**
+or by opening a private conversation with a maintainer through GitHub. This
+mirrors the reporting channel documented in [`SECURITY.md`](SECURITY.md) —
+there is no separate public issue tracker for conduct reports, to keep the
+report private by default.
+
+All complaints will be reviewed and investigated promptly and fairly. All
+community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of
+Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels.
+Violating these terms may lead to a temporary or permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][mozilla coc].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][faq]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[mozilla coc]: https://github.com/mozilla/diversity
+[faq]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 9304aa3c..039dc453 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -2,7 +2,7 @@
## Code of Conduct
-This project follows the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
+This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
## How to Contribute
@@ -24,4 +24,4 @@ See [`docs/CI.md`](../docs/CI.md) for full CI documentation.
## Security
-See [`SECURITY.md`](../SECURITY.md) for vulnerability reporting.
\ No newline at end of file
+See [`SECURITY.md`](SECURITY.md) for vulnerability reporting.
\ No newline at end of file
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 443a14ef..763cf559 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -32,7 +32,7 @@ contexts/ → React context providers (one per major view + I18nContext
features/ → Redux Toolkit slices: project, settings, status, writer, versionControl, featureFlags
hooks/ → Custom hooks with view business logic (one hook per view)
services/ → External adapters: geminiService, aiProviderService, dbService (dual IndexedDB + migration), storageService, collaborationService; **ai/** (aiModeService — execution modes, aiPolicy, aiRetry; **providers/** — openrouterProvider with circuit breaker); **copilot/** (heuristicEngine 8 rules, insightGenerator, copilotContextService, actionApplier); **commands/** (palette registry); **keyboard/** (shortcut matching); **help/** (doc retrieval for AI); **settingsExchange** (settings JSON)
-locales/ → i18n source files — de/en/es/fr/it (core) + ar/he/fa (RTL Beta) + el/ja/pt/zh/fi/sv/hu/is/eu (Beta) × ~20 JSON modules (17 locales; see the README badge for the live key count)
+locales/ → i18n source files — de/en/es/fr/it (core) + ar/he/fa (RTL Beta) + el/ja/pt/zh/fi/sv/hu/is/eu/ru/ko (Beta) × 21 JSON modules (19 locales; see the README badge for the live key count)
public/locales/ → i18n runtime files served at BASE_URL
tests/ → Unit + E2E tests (Vitest + Playwright)
types/ → Additional TypeScript type definitions
@@ -144,7 +144,7 @@ types.ts → Core shared interfaces and types
- All user-facing strings must use `t('key.path')` from `useTranslation()`
- Source files: `locales/{lang}/{module}.json` (15 modules). Runtime: **one** merged **`public/locales/{lang}/bundle.json`** per language — regenerate with **`pnpm run i18n:bundle`** or **`pnpm run i18n:check`** (parity check **and** bundle build); **`predev`** / **`prebuild`** also rebuild bundles so the UI never shows raw keys after editing locale JSON.
-- **17 locales ship** (de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu Beta); all must keep key parity with English (`pnpm run i18n:check` in CI). The `/i18n-key` skill auto-fills the **5 core** (de/en/es/fr/it); update Beta/RTL locales manually afterward.
+- **19 locales ship** (de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu/ru/ko Beta); all must keep key parity with English (`pnpm run i18n:check` in CI). The `/i18n-key` skill auto-fills the **5 core** (de/en/es/fr/it); update Beta/RTL locales manually afterward.
- English is the fallback language
- New keys: add to **`locales/en/`** first, then **de**, **fr**, **es**, **it** (or run `node scripts/check-i18n-keys.mjs --fix` and translate), then commit updated **`bundle.json`** files
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a3cec063..777912a8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -99,9 +99,12 @@ jobs:
# QNBS-v3: Explicit versions — `node` (= 'current') breaks CI without code changes when Node N+1 is released.
node-version: ['22', '24']
steps:
+ # QNBS-v3: fetch-tags — check-doc-metrics.mjs's stale-PLANNED-status check reads `git tag`
+ # and silently no-ops without one; a default shallow checkout has no tags at all.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
persist-credentials: false
+ fetch-tags: true
- uses: ./.github/actions/setup
with:
node-version: ${{ matrix.node-version }}
@@ -118,6 +121,9 @@ jobs:
- name: i18n key parity (vs en)
run: pnpm run i18n:check
+ - name: Doc metrics drift gate (locale/key counts, stale PLANNED status)
+ run: pnpm run docs:check
+
- name: Feature parity audit
run: pnpm run parity:check
@@ -136,6 +142,11 @@ jobs:
NODE_OPTIONS: "--no-experimental-webstorage --max-old-space-size=4096"
CI: "true"
+ - name: Coverage ratchet check (informational, non-blocking)
+ if: always()
+ continue-on-error: true
+ run: node scripts/check-coverage-ratchet.mjs
+
- name: Upload Vitest JSON results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a8839632..2f585e46 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,6 +4,7 @@ Thank you for your interest in contributing to WorldScript Studio — an AI-powe
## Table of Contents
+- [Code of Conduct](#code-of-conduct)
- [Development Setup](#development-setup)
- [Tech Stack](#tech-stack)
- [Project Structure](#project-structure)
@@ -17,6 +18,10 @@ Thank you for your interest in contributing to WorldScript Studio — an AI-powe
---
+## Code of Conduct
+
+This project follows the [Contributor Covenant Code of Conduct](.github/CODE_OF_CONDUCT.md). By participating, you are expected to uphold it — report unacceptable behavior via [GitHub Private Vulnerability Reporting](https://github.com/qnbs/WorldScript-Studio/security/advisories/new), the same private channel documented in [`.github/SECURITY.md`](.github/SECURITY.md).
+
## Development Setup
### Prerequisites
@@ -122,7 +127,7 @@ WorldScript-Studio/
├── contexts/ # React contexts per view + I18n + CommandExecutor
├── features/ # Redux slices (project, settings, writer, status, featureFlags, plotBoard, progressTracker, sceneComments)
├── hooks/ # Custom React hooks per view + shared hooks (e.g. useGlobalKeyboardShortcuts)
-├── locales/ # i18n source JSON — 17 locales: de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu Beta (key parity vs en)
+├── locales/ # i18n source JSON — 19 locales: de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu/ru/ko Beta (key parity vs en)
├── services/ # Adapters: AI, DB, storage, collaboration, EPUB; commands/, keyboard/, help/, settingsExchange
├── stories/ # Storybook stories for UI components
├── docs/ # Deep-dive docs (CI reference, history, graphify)
diff --git a/README.md b/README.md
index 9395ebff..208b9c44 100644
--- a/README.md
+++ b/README.md
@@ -12,16 +12,13 @@
-
-
+
+
-
-
-
---
@@ -351,7 +348,7 @@ One-click encrypted export of your entire project library from **Settings → Da
### 🌐 Full Multi-Language Support
-Shipped UI locales with **2859 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
+Shipped UI locales with **2849 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
- 🇩🇪 **German** (Deutsch)
- 🇬🇧 **English**
@@ -460,8 +457,8 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **PDF Export** | jsPDF | Client-side, configurable PDF document generation |
| **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`) | 2859 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (5807+ tests / 527 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **i18n** | Custom React Context (`I18nContext.tsx`) | 2849 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence |
+| **Testing** | Vitest 4.x (5807+ tests / 528 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` |
@@ -492,13 +489,13 @@ WorldScript-Studio/
│ ├── keyboard/ # Shortcut matching, OS normalization, conflict detection
│ ├── help/ # RAG-lite retrieval for in-app help articles
│ └── settingsExchange/ # Zod-validated settings import/export
-├── locales/ # i18n source JSON (de/en/es/fr/it × 15 modules each)
+├── locales/ # i18n source JSON (19 locales × 21 modules each)
├── public/
│ ├── locales/ # i18n runtime bundles (rebuilt by pnpm run i18n:check / prebuild)
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (5807+ tests, 527 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (5807+ tests, 528 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -657,9 +654,9 @@ 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-07-28):**
-- **5807+ unit tests** across **527 test files** — all passing
+- **5807+ unit tests** across **528 test files** — all passing
- Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics)
-- i18n: **2859 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta)
+- i18n: **2849 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta)
**CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist.
diff --git a/ROADMAP.md b/ROADMAP.md
index bb9290a0..ab5a0fb7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -22,11 +22,11 @@ Benchmarks from the UI/PWA deep-dive (implemented in repo, no new mandatory docs
---
-## Upcoming — v1.24 / v2.0 Foundation (PLANNED)
+## Upcoming — v2.0 Foundation (PLANNED)
-Forward-looking work carried out of the v1.23 cycle:
+Forward-looking work carried out of the v1.24 cycle (i18n expansion and the six-PR hardening
+sequence below both shipped in v1.24.0/v1.24.1 — see the RELEASED sections):
-- **i18n — 17 locales (✅ bulk-translated):** added `fi/sv/hu/is/eu` (Beta) + `fa` (RTL, Persian). Priority chrome hand-translated; **all remaining modules machine-translated** (glossary v2.0, placeholder-masked) to ~96-100 % across all 10 Beta locales. Remaining: **human native review** (checklist in [`docs/TRANSLATION-GUIDE.md`](docs/TRANSLATION-GUIDE.md)). See [`docs/LANGUAGE-EXPANSION-2026.md`](docs/LANGUAGE-EXPANSION-2026.md).
- **Coverage targets:** lift toward L≥85 % / B≥75 % / F≥80 % — focus on AI routing, Voice, Copilot.
- **Local AI & Voice hardening:** Whisper/Kokoro on low-end devices, model-integrity checks, Eco-Mode.
- **Error boundaries + structured logging** for AI/worker failures.
@@ -35,6 +35,29 @@ Forward-looking work carried out of the v1.23 cycle:
- **Plugin Registry Beta:** graduate from the sandbox-hardening phase.
- **Bundle optimisation:** further code-splitting for heavy chunks.
- **Local-first:** advance ADR-0008 — B2.1 Y.Text migration → B2.2 SoT flip behind `enableLocalFirstSync`.
+- **i18n human native review:** 14 Beta/RTL locales (all except de/en/es/fr/it) are machine-translated to ~96-100 %; native-speaker review still open (checklist in [`docs/TRANSLATION-GUIDE.md`](docs/TRANSLATION-GUIDE.md)). See [`docs/LANGUAGE-EXPANSION-2026.md`](docs/LANGUAGE-EXPANSION-2026.md).
+
+---
+
+## v1.24.1 — Local-AI Reliability, Heuristic Fallbacks, LanguageTool (RELEASED 2026-07-28)
+
+**Status:** ✅ Released — see [`CHANGELOG.md`](CHANGELOG.md) `[1.24.1]`.
+
+- Local-AI reliability: desktop Ollama/LM Studio/vLLM discovery fixes, corrected a misleading browser AI-status badge.
+- AI heuristic-fallback foundation (ADR-0011) + per-feature generators for Outline, Character, World profiles, and Plot Board "suggest next beat" — offline-capable degradation when cloud/local AI is unavailable.
+- Real self-hosted LanguageTool grammar/spelling integration (privacy-first, replaces a placeholder typo list).
+- 20-PR Dependabot backlog triage (16 merged, 4 closed as superseded after root-causing AI SDK v4/Biome 2.5.x/dev-tooling blockers).
+- Issue #60 vendor-fork audit closeout (`packages/collab-transport` vs. upstream y-webrtc 10.3.0).
+- Security/build hardening: `persist-credentials` (CWE-522), a Tailwind `ltr`/`rtl` fix, 3 ineffective dynamic imports, 2 CWE-209 fixes.
+
+---
+
+## v1.24.0 — Critical Hardening + 17→19 Locale Expansion (RELEASED 2026-06-21)
+
+**Status:** ✅ Released — see [`CHANGELOG.md`](CHANGELOG.md) `[1.24.0]`.
+
+- Six stacked PRs: privacy, experimental-feature labeling, coverage, voice consent, local-AI, and doc/hygiene hardening.
+- i18n expansion: added `fi/sv/hu/is/eu` (Beta) + `fa` (RTL, Persian) — 11→17 locales at release time; `ru`/`ko` followed in a later v1.24.x patch, bringing the current total to **19**. Priority chrome hand-translated; remaining modules machine-translated (glossary v2.0, placeholder-masked) to ~96-100 % across all Beta locales.
---
diff --git a/TODO.md b/TODO.md
index 9d65de47..d02d680a 100644
--- a/TODO.md
+++ b/TODO.md
@@ -15,6 +15,7 @@ Status: 🔄 in progress | ⬜ open | ✅ done
- ✅ **Feature catalog reconciled** — `featureCatalog.ts` covers all 23 flags; `defaultOn` derived from the slice (drift now impossible, guarded by `tests/unit/featureCatalog.test.ts`); added risk/desktop/dependency metadata.
- ✅ **Grouped Settings UI** — Experimental flags grouped by category with risk hints, dependency-aware disabling (Voice WASM ⇠ Voice Support), "Desktop only" note (Rust Compute), and Reset-to-defaults.
- ⬜ **WebNN flag decision** — `enableWebnnInference` is a ghost/stub (no runtime gate reads it). Either wire `selectEnableWebnnInference` into the ONNX RT WebNN provider selection, or retire the flag.
+- ⬜ **CII Best Practices badge** — removed from `README.md` (was a literal `projects/XXXX` placeholder — broken image, dead link). Register the project at (external, interactive signup — maintainer-only action), then re-add the badge with the real project ID.
## v1.23 — P0 Audit Follow-up (DELIVERED 2026-06-16)
diff --git a/docs/CI.md b/docs/CI.md
index 3d246163..f836ba1e 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -15,7 +15,7 @@ For historical optimization notes (targets may predate the live workflow), see [
| Tier | Where | Commands / scope |
|------|--------|------------------|
| **Quick (local)** | Developer laptop | `pnpm run lint`, `pnpm run typecheck`, `pnpm run i18n:check`; optional `pnpm exec vitest run` **without** `--coverage` for a fast smoke |
-| **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Stryker (informational), Storybook static build, bundle budget + analyze |
+| **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Storybook static build, bundle budget + analyze. Mutation testing (Stryker) is **not** part of this pipeline — see [Mutation testing status](#mutation-testing-status). |
**Merge readiness:** A green workflow run on the PR/branch matters more than reproducing every E2E or LHCI step locally. Use CI **artifacts** (Playwright HTML report, coverage, Lighthouse output) to debug failures.
@@ -73,20 +73,20 @@ Each job that uses the composite must call `actions/checkout@v6` first (local co
```text
security ──► quality ──┬──► build ──┬──► lighthouse
├──► e2e └──► vrt
- ├──► storybook
- └──► mutation (Stryker; informational score gate)
+ └──► storybook
build (main, non-PR) ──► upload-pages-artifact
deploy (main, non-PR) needs: build + e2e ──► GitHub Pages
```
+Mutation testing (Stryker) is **not** in this graph — it runs only via manual `workflow_dispatch` on [`mutation.yml`](../.github/workflows/mutation.yml). See [Mutation testing status](#mutation-testing-status).
+
| Job | Needs | Purpose |
|-----|--------|---------|
| `security` | — | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` |
-| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run parity:check`**, `tsc`, Vitest + coverage, Codecov (optional token), coverage artifact |
+| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov (optional token), coverage artifact |
| `build` | `quality` | Production `pnpm run build`, **`bundle:budget`**, **`analyze`** (upload `bundle-analysis.html`), `dist` artifact; on `main` (non-PR): Pages artifact + **SLSA build provenance attestation** |
| `e2e` | `quality` | Playwright **Chromium** + **Mobile Chrome** (Pixel 5) — `CI=true`, 2× retries, 50 min timeout; browser cache via `actions/cache@v5`. Firefox optional locally. `PLAYWRIGHT_SKIP_VRT=true` (VRT is its own job). |
-| `mutation` | `quality` | **`pnpm run mutation`** if `stryker.conf.json` exists; **`break: 75`** → score <75% fails the job (timeout at 20 min is expected on shared runners — not a blocker for deploy) |
| `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. |
| `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--max-workers=2 --retries=3 --screenshot-on-failure`; artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. |
| `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium); artifacts uploaded always |
@@ -96,6 +96,28 @@ deploy (main, non-PR) needs: build + e2e ──► GitHub Pages
---
+## Mutation testing status
+
+Stryker was removed from the `quality` job on **2026-06-02** — it ran as a flaky, non-gating check
+that added noise to every PR without blocking anything (see the `QNBS-v3` comment above the `build`
+job in [`ci.yml`](../.github/workflows/ci.yml)). It now runs **only** via manual trigger:
+
+```bash
+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.
+
+**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.
+
+---
+
## Supply-chain security
| Tool | Trigger | Output |
diff --git a/docs/CODE_QUALITY.md b/docs/CODE_QUALITY.md
index 4f898b39..a866fbdf 100644
--- a/docs/CODE_QUALITY.md
+++ b/docs/CODE_QUALITY.md
@@ -53,6 +53,22 @@ ref.current.innerHTML = DOMPurify.sanitize(html);
- When you refactor code that allows removing a suppression, remove it and run `node scripts/check-suppressions.mjs --update` to ratchet the baseline down.
- The CI gate prints a per-rule breakdown; use `node scripts/check-suppressions.mjs --details` for a per-file breakdown.
+### Suppression Reduction Plan
+
+The baseline sits at **52/52 — no headroom**; any new suppression fails the gate outright. `lint/suspicious/noExplicitAny` is the largest single rule (33 of 52, across 20 test files — all in `tests/unit/`, none in application code). Grouped by cluster:
+
+Cluster counts below are from `node scripts/check-suppressions.mjs --details`, not estimates — they sum to exactly 52.
+
+| Cluster | Files | Count | Effort |
+|---|---|---|---|
+| Voice/WASM browser-API mocking | `tests/unit/voice/wasmSttEngine.test.ts` (7), `tests/unit/voice/sttEngine.test.ts` (4) | 11 | High — these mock `MediaRecorder`/constructor-style globals and reach into private test internals; a properly-typed mock would need real `MediaRecorder`/Worker type shims, not a quick fix. |
+| `useAppSelector`/`useAppSelectorShallow` mock casts | `tests/unit/SceneBoardView.test.tsx` (2); `BackupQuickActionsCard.test.tsx`, `ConnectionToolbar.test.tsx`, `ReferencePanelView.test.tsx`, `hooks/useProForgeOrchestrator.test.ts`, `hooks/useSceneBoardView.test.ts`, `settings/ProjectAiPresetSection.test.tsx` (1 each) — 7 files per the existing convention note in [`tests/CLAUDE.md`](../tests/CLAUDE.md) | 8 | Medium — `tests/CLAUDE.md` already identifies the fix: one shared, properly-typed helper (`(selector: (s: RootState) => unknown) => selector(mockState as RootState)`) replacing all 8 call sites removes this whole cluster in one dedicated PR. |
+| Voice service test mocks | `tests/unit/services/voice/voiceDownloadAndIntent.test.ts` (2), `voiceCommandService.test.ts` (2), `voiceActivityCoordinator.test.ts` (1) | 5 | Medium — similar shape to the browser-API cluster above, smaller scope per file. |
+| Single-occurrence hook/thunk mocks | `characterInterviewTemplates.test.ts` (2); `ManuscriptView.test.tsx`, `cloudSyncBackend.test.ts`, `hooks/useAdaptiveAi.test.ts`, `hooks/usePlotBoardAi.test.ts`, `lora/loraThunks.test.ts`, `manuscript/NavigatorPanel.test.tsx`, `thunks/interviewThunks.test.ts` (1 each) | 9 | Low-Medium — each is an isolated mock-typing gap; fix opportunistically when touching that test file rather than as a dedicated sweep. |
+| Remaining rules (`useExhaustiveDependencies` 6, `useSemanticElements` 5, `noDangerouslySetInnerHtml` 2, `useArrowFunction` 2, `noThenProperty` 1, `useAriaPropsSupportedByRole` 1, `noEmptyPattern` 1, `noConstructorReturn` 1) | Scattered across `App.tsx`, `components/`, `hooks/` | 19 | Low — each is a one-off, case-by-case judgment call already documented inline at the suppression site. |
+
+No code changes in this pass — this section exists purely to make the debt visible instead of it silently accumulating one suppression at a time, the state it was in before this table existed. The actual paydown is separate, future work.
+
---
## Security-sensitive coding patterns
diff --git a/docs/TRANSLATION-GUIDE.md b/docs/TRANSLATION-GUIDE.md
index 0c1a10a7..77c4d618 100644
--- a/docs/TRANSLATION-GUIDE.md
+++ b/docs/TRANSLATION-GUIDE.md
@@ -4,8 +4,8 @@
> terminology reference in [`I18N-GLOSSARY.md`](I18N-GLOSSARY.md) and the rollout notes in
> [`LANGUAGE-EXPANSION-2026.md`](LANGUAGE-EXPANSION-2026.md).
-WorldScript Studio ships **17 locales**: `de` `en` `es` `fr` `it` (core, fully translated) ·
-`ar` `he` `fa` (RTL Beta) · `ja` `zh` `pt` `el` `fi` `sv` `hu` `is` `eu` (Beta). Beta locales are
+WorldScript Studio ships **19 locales**: `de` `en` `es` `fr` `it` (core, fully translated) ·
+`ar` `he` `fa` (RTL Beta) · `ja` `zh` `pt` `el` `fi` `sv` `hu` `is` `eu` `ru` `ko` (Beta). Beta locales are
machine-translated (glossary-anchored) and await human native review.
---
@@ -14,12 +14,12 @@ machine-translated (glossary-anchored) and await human native review.
- **No i18next.** A custom React context (`contexts/I18nContext.tsx`) is the runtime. Components read
strings via `const { t } = useTranslation()` and call `t('module.key.path')`.
-- **Source of truth:** `locales//.json` — **20 modules** (`common`, `settings`,
- `writer`, `manuscript`, `copilot`, `dashboard`, `outline`, `worlds`, `characters`, `export`,
- `help`, `tour`, `portal`, `templates`, `sidebar`, `tags`, `objects`, `mindmap`, `lora`,
+- **Source of truth:** `locales//.json` — **21 modules** (`common`, `settings`,
+ `writer`, `manuscript`, `copilot`, `dashboard`, `desktop`, `outline`, `worlds`, `characters`,
+ `export`, `help`, `tour`, `portal`, `templates`, `sidebar`, `tags`, `objects`, `mindmap`, `lora`,
`characterInterviews`). Files are **flat JSON** — keys are the full dotted path
(`"portal.welcome.title": "…"`), not nested objects.
-- **Runtime bundles:** `pnpm run i18n:bundle` (or `i18n:check`) merges the 20 module files per
+- **Runtime bundles:** `pnpm run i18n:bundle` (or `i18n:check`) merges the 21 module files per
language into `public/locales//bundle.json`. **Never hand-edit a bundle** — edit the source
module and rebuild.
- **English is the reference.** Every locale must hold the exact same key set as `locales/en`
@@ -28,7 +28,7 @@ machine-translated (glossary-anchored) and await human native review.
### Commands
```bash
-pnpm run i18n:check # key parity across all 17 locales + rebuild bundles + content guard
+pnpm run i18n:check # key parity across all 19 locales + rebuild bundles + content guard
pnpm run i18n:bundle # rebuild public/locales//bundle.json only
node scripts/check-i18n-keys.mjs --quality # report likely-untranslated (EN-identical) strings per locale
node scripts/check-i18n-keys.mjs --fix # fill MISSING keys in EXISTING files with the EN value (does NOT create files)
diff --git a/docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md b/docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md
index 0264ef72..5297d2ce 100644
--- a/docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md
+++ b/docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md
@@ -7,7 +7,7 @@
## 1. Current state (as audited)
-- **17 locales × 21 modules**, ~2793 keys, full **key parity** enforced by
+- **19 locales × 21 modules**, ~2849 keys, full **key parity** enforced by
`scripts/check-i18n-keys.mjs` (CI `quality` job) + bundled to `public/locales//bundle.json`.
- Runtime: custom `contexts/I18nContext.tsx` — lazy `bundle.json` fetch, `localStorage` persistence
(`worldscript-language`), English fallback chain, cached `Intl.*` helpers, `Intl.Segmenter` word
diff --git a/package.json b/package.json
index 84f3f97c..e10dbd3c 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"i18n:status": "tsx scripts/i18n-status-dashboard.ts",
"content:guard": "node scripts/content-guard.mjs",
"parity:check": "tsx scripts/audit-feature-parity.ts",
+ "docs:check": "node scripts/check-doc-metrics.mjs",
"suppressions:check": "node scripts/check-suppressions.mjs",
"token:audit": "node scripts/audit-tokens.mjs",
"sync:readme": "node scripts/sync-readme-metrics.mjs",
diff --git a/scripts/check-coverage-ratchet.mjs b/scripts/check-coverage-ratchet.mjs
new file mode 100644
index 00000000..0debf479
--- /dev/null
+++ b/scripts/check-coverage-ratchet.mjs
@@ -0,0 +1,80 @@
+#!/usr/bin/env node
+/**
+ * Non-blocking: compares coverage/coverage-summary.json (produced by `pnpm run test:coverage`,
+ * the json-summary reporter in vitest.config.ts) against the configured coverage thresholds, and
+ * prints a suggestion when a metric sits comfortably above its threshold. The ratchet-up decision
+ * stays with the maintainer (vitest.config.ts's own comment documents a "3 consecutive green runs,
+ * max 5 points/quarter" rule) — this script only makes the opportunity visible instead of it
+ * quietly rotting, the way the threshold history shows it has before.
+ *
+ * Run: node scripts/check-coverage-ratchet.mjs
+ * Always exits 0 — never gates CI; wire it as a non-blocking informational step.
+ */
+import { existsSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+const SUMMARY_PATH = join(root, 'coverage', 'coverage-summary.json');
+
+// QNBS-v3: mirrors vitest.config.ts's `coverage.thresholds` — that file isn't a plain-data module
+// (it's a defineConfig() call), so this can't import it directly. Keep both in sync by hand; a
+// mismatch here only makes a suggestion's "vs. threshold" number wrong, it can't mask a real
+// coverage regression (vitest.config.ts's own thresholds still gate the actual build).
+const THRESHOLDS = { lines: 74, functions: 67, branches: 60, statements: 72 };
+const RATCHET_GAP = 2;
+
+// QNBS-v3: every exit path is a `return`, never `process.exit(1)` — this is advisory-only, so a missing/malformed/all-clean summary must all fall through to the same non-blocking outcome.
+function main() {
+ if (!existsSync(SUMMARY_PATH)) {
+ process.stdout.write(
+ '[coverage-ratchet] No coverage/coverage-summary.json found — run `pnpm run test:coverage` first. Skipping (non-blocking).\n',
+ );
+ return;
+ }
+
+ // QNBS-v3: a truncated/invalid summary must not violate the "always exits 0" contract this
+ // script documents — catch parse failures the same way a missing file is already handled.
+ let summary;
+ try {
+ summary = JSON.parse(readFileSync(SUMMARY_PATH, 'utf8'));
+ } catch {
+ process.stdout.write(
+ '[coverage-ratchet] Could not parse coverage-summary.json — skipping (non-blocking).\n',
+ );
+ return;
+ }
+ const total = summary.total;
+ if (!total) {
+ process.stdout.write(
+ '[coverage-ratchet] coverage-summary.json has no "total" field — skipping.\n',
+ );
+ return;
+ }
+
+ const suggestions = [];
+ for (const [metric, threshold] of Object.entries(THRESHOLDS)) {
+ const pct = total[metric]?.pct;
+ if (typeof pct !== 'number') continue;
+ if (pct - threshold >= RATCHET_GAP) {
+ suggestions.push(
+ `${metric} ${pct.toFixed(2)} vs. threshold ${threshold} → consider raising to ${Math.floor(pct) - 1}`,
+ );
+ }
+ }
+
+ if (suggestions.length === 0) {
+ process.stdout.write(
+ `[coverage-ratchet] No threshold has ${RATCHET_GAP}+ points of headroom — nothing to ratchet yet.\n`,
+ );
+ return;
+ }
+
+ process.stdout.write(
+ `[coverage-ratchet] Ratchet fällig — see docs/COVERAGE-POLICY.md before raising:\n${suggestions
+ .map((s) => ` - ${s}`)
+ .join('\n')}\n`,
+ );
+}
+
+main();
diff --git a/scripts/check-doc-metrics.d.mts b/scripts/check-doc-metrics.d.mts
new file mode 100644
index 00000000..7bc76b4e
--- /dev/null
+++ b/scripts/check-doc-metrics.d.mts
@@ -0,0 +1,9 @@
+export function stripHistoricalSections(markdown: string): string;
+export function getActualLocaleCount(): number;
+export function getActualKeyCount(): number;
+export function getLatestReleasedVersion(): string | null;
+export function scanForDrift(
+ content: string,
+ filePath: string,
+ actual: { localeCount: number; keyCount: number; latestVersion: string | null },
+): string[];
diff --git a/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs
new file mode 100644
index 00000000..ace9c6b6
--- /dev/null
+++ b/scripts/check-doc-metrics.mjs
@@ -0,0 +1,192 @@
+#!/usr/bin/env node
+/**
+ * Fails if a doc file states a locale/key count or a release-status claim that no longer matches
+ * reality — the drift this audit found repeatedly (ROADMAP.md/TRANSLATION-GUIDE.md/TODO.md/
+ * CONTRIBUTING.md/.github/copilot-instructions.md all said "17 locales" after the 17→19 expansion).
+ * Historical/dated entries (CHANGELOG-style `## [x.y.z]` headings, or `## vX.Y.Z … RELEASED …`
+ * section headings) are exempt — they correctly describe a past state, not the present one.
+ *
+ * Run: node scripts/check-doc-metrics.mjs
+ */
+import { execSync } from 'node:child_process';
+import { readdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { getModules, REF_LANG } from './i18n-locales.mjs';
+
+const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+
+// QNBS-v3: every file the header comment names as a past drift site must actually be scanned —
+// CodeRabbit caught .github/copilot-instructions.md missing here, which would let that exact
+// regression recur silently past this gate.
+const TARGET_FILES = [
+ 'README.md',
+ 'ROADMAP.md',
+ 'TODO.md',
+ 'docs/TRANSLATION-GUIDE.md',
+ 'CONTRIBUTING.md',
+ '.github/CONTRIBUTING.md',
+ '.github/copilot-instructions.md',
+];
+
+// QNBS-v3: this repo marks "done" several different ways — Keep-a-Changelog `## [x.y.z]`,
+// `## vX.Y.Z … RELEASED …`, a heading suffixed with ✅ (`### Phase 3A … ✅`), a `**Status:** ✅
+// Released/Completed …` line just below a plain `## vX.Y — ` heading, or a dated heading
+// `(YYYY-MM-DD)` — a section carrying ANY of these near its top is a historical snapshot, exempt
+// from present-tense metric checks. Independently, this repo's own TODO.md legend ("✅ done") means
+// any individual `- ✅ …` bullet is historical regardless of which section it sits in.
+const ANY_HEADING = /^#{1,6}\s+/;
+const HISTORICAL_MARKER =
+ /(✅|\bRELEASED\b|\bDELIVERED\b|\bCompleted\b|\[\d+\.\d+\.\d+\]|\(\d{4}-\d{2}-\d{2})/i;
+const DONE_BULLET = /^\s*-\s*✅/;
+// QNBS-v3: how many lines below a heading to look for a "**Status:** ✅ Released …" marker that
+// applies to the whole section (this repo puts it on its own line, not in the heading text).
+const STATUS_LOOKAHEAD = 5;
+
+/**
+ * Blank out lines that fall in a historical section (see above), keeping line numbers stable so
+ * findings still point at the right place in the ORIGINAL file for anything that isn't blanked.
+ */
+export function stripHistoricalSections(markdown) {
+ const lines = markdown.split('\n');
+ const headingIdx = [];
+ lines.forEach((line, i) => {
+ if (ANY_HEADING.test(line)) headingIdx.push(i);
+ });
+ headingIdx.push(lines.length); // sentinel so the last section has an end bound
+
+ const historical = new Array(lines.length).fill(false);
+ for (let s = 0; s < headingIdx.length - 1; s++) {
+ const start = headingIdx[s];
+ const end = headingIdx[s + 1];
+ const lookahead = lines.slice(start, Math.min(end, start + STATUS_LOOKAHEAD));
+ if (lookahead.some((l) => HISTORICAL_MARKER.test(l))) {
+ for (let i = start; i < end; i++) historical[i] = true;
+ }
+ }
+
+ return lines.map((line, i) => (historical[i] || DONE_BULLET.test(line) ? '' : line)).join('\n');
+}
+
+/** Actual locale count = directories under locales/ (translation-glossary.json is a file, not a locale). */
+// QNBS-v3: reads the filesystem directly rather than a hand-maintained constant, so a new locale is picked up automatically instead of going stale like the doc claims this gate exists to catch.
+export function getActualLocaleCount() {
+ return readdirSync(join(root, 'locales'), { withFileTypes: true }).filter((e) => e.isDirectory())
+ .length;
+}
+
+/** Actual key count = deduplicated key set across all modules for the reference locale (matches check-i18n-keys.mjs). */
+// QNBS-v3: Set-based dedup, not a per-file sum — a key shared by two modules must count once, the exact bug this gate caught in sync-readme-metrics.mjs's own counting logic.
+export function getActualKeyCount() {
+ const keys = new Set();
+ for (const mod of getModules()) {
+ const p = join(root, 'locales', REF_LANG, `${mod}.json`);
+ const data = JSON.parse(readFileSync(p, 'utf8'));
+ for (const k of Object.keys(data)) keys.add(k);
+ }
+ return keys.size;
+}
+
+/** Latest released version tag (e.g. "1.24.1"), or null if no tags exist (e.g. a shallow clone). */
+// QNBS-v3: null (not a thrown error) on a shallow/tagless checkout — callers must treat "no tags" as "skip the PLANNED check", never as a drift finding of its own.
+export function getLatestReleasedVersion() {
+ try {
+ const tag = execSync('git tag --sort=-v:refname', { cwd: root, encoding: 'utf8' })
+ .split('\n')
+ .find((t) => t.trim().length > 0);
+ return tag ? tag.trim().replace(/^v/, '') : null;
+ } catch {
+ return null;
+ }
+}
+
+function semverLte(a, b) {
+ const pa = a.split('.').map(Number);
+ const pb = b.split('.').map(Number);
+ for (let i = 0; i < 3; i++) {
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0);
+ }
+ return true; // equal
+}
+
+/**
+ * Scan one file's (historical-stripped) content for locale-count / key-count / stale-PLANNED
+ * drift against the actual computed values. Returns human-readable finding strings.
+ */
+// QNBS-v3: regex-scans prose for numeric claims rather than requiring structured metadata — this gate exists precisely because docs drift in free-form text, not in a machine-checked field.
+export function scanForDrift(content, filePath, { localeCount, keyCount, latestVersion }) {
+ const findings = [];
+ const scanned = stripHistoricalSections(content);
+ const lines = scanned.split('\n');
+
+ lines.forEach((line, i) => {
+ for (const m of line.matchAll(/(\d+)\s+locales?\b/gi)) {
+ const found = Number(m[1]);
+ if (found !== localeCount) {
+ findings.push(
+ `${filePath}:${i + 1} — says "${found} locale(s)", actual is ${localeCount}: "${line.trim()}"`,
+ );
+ }
+ }
+ for (const m of line.matchAll(/(\d+)\s+(?:i18n\s+)?keys\b/gi)) {
+ const found = Number(m[1]);
+ if (found !== keyCount) {
+ findings.push(
+ `${filePath}:${i + 1} — says "${found} keys", actual is ${keyCount}: "${line.trim()}"`,
+ );
+ }
+ }
+ if (latestVersion) {
+ for (const m of line.matchAll(/v?(\d+\.\d+(?:\.\d+)?)[^\n]*\bPLANNED\b/gi)) {
+ const mentioned = m[1];
+ if (semverLte(mentioned, latestVersion)) {
+ findings.push(
+ `${filePath}:${i + 1} — "v${mentioned} … PLANNED" but v${mentioned} <= latest released v${latestVersion}: "${line.trim()}"`,
+ );
+ }
+ }
+ }
+ });
+
+ return findings;
+}
+
+// QNBS-v3: exits 1 on any finding — unlike check-coverage-ratchet.mjs this gate is blocking, since a doc claiming a wrong locale/key/release count is actively misleading, not just an opportunity.
+function main() {
+ const localeCount = getActualLocaleCount();
+ const keyCount = getActualKeyCount();
+ const latestVersion = getLatestReleasedVersion();
+
+ const allFindings = [];
+ for (const relPath of TARGET_FILES) {
+ const abs = join(root, relPath);
+ let content;
+ try {
+ content = readFileSync(abs, 'utf8');
+ } catch {
+ continue; // file doesn't exist in this checkout — not this gate's concern
+ }
+ allFindings.push(...scanForDrift(content, relPath, { localeCount, keyCount, latestVersion }));
+ }
+
+ if (allFindings.length > 0) {
+ process.stderr.write(
+ `[docs:check] DOC METRICS DRIFT — ${allFindings.length} finding(s):\n${allFindings
+ .map((f) => ` - ${f}`)
+ .join('\n')}\n`,
+ );
+ process.exit(1);
+ }
+
+ process.stdout.write(
+ `[docs:check] OK — ${TARGET_FILES.length} files match actual state (${localeCount} locales, ${keyCount} keys${
+ latestVersion ? `, latest v${latestVersion}` : ''
+ }).\n`,
+ );
+}
+
+// QNBS-v3: only run the CLI side-effect when invoked directly — stripHistoricalSections/
+// scanForDrift/getActual* stay importable (and independently testable) from a unit test.
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ main();
+}
diff --git a/scripts/sync-readme-metrics.mjs b/scripts/sync-readme-metrics.mjs
index adb7a17e..414d61fc 100644
--- a/scripts/sync-readme-metrics.mjs
+++ b/scripts/sync-readme-metrics.mjs
@@ -45,14 +45,6 @@ function countTestFiles(dir) {
return count;
}
-/** Count leaf values in a nested JSON object. */
-function countLeaves(obj) {
- return Object.values(obj).reduce(
- (acc, v) => acc + (v && typeof v === 'object' ? countLeaves(v) : 1),
- 0,
- );
-}
-
function getTestFileCount() {
// Mirror the three vitest.config.ts include roots; tests/e2e is excluded inside countTestFiles.
return (
@@ -64,14 +56,19 @@ function getTestFileCount() {
);
}
+// QNBS-v3: Set-based dedup across module files — a key defined in two files (e.g. a shared
+// key living in both settings.json and its canonical home module) must count once, matching
+// check-i18n-keys.mjs's loadBundleKeys(). A per-file leaf sum double-counted such keys, which is
+// exactly what caused this badge to drift from the real, deduplicated i18n:check count.
function getKeyCount() {
const localeDir = join(root, 'locales', 'en');
- let total = 0;
+ const keys = new Set();
for (const file of readdirSync(localeDir)) {
if (!file.endsWith('.json')) continue;
- total += countLeaves(JSON.parse(readFileSync(join(localeDir, file), 'utf8')));
+ const data = JSON.parse(readFileSync(join(localeDir, file), 'utf8'));
+ for (const k of Object.keys(data)) keys.add(k);
}
- return total;
+ return keys.size;
}
// QNBS-v3: locale count is the number of per-language directories under locales/ (each ships a
diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts
new file mode 100644
index 00000000..63c7a602
--- /dev/null
+++ b/tests/unit/checkDocMetrics.test.ts
@@ -0,0 +1,99 @@
+// @vitest-environment node
+/**
+ * Tests for scripts/check-doc-metrics.mjs
+ * QNBS-v3: protects the drift gate from historical-section regressions — an untested exclusion heuristic would turn it into noise.
+ */
+import { describe, expect, it } from 'vitest';
+import { scanForDrift, stripHistoricalSections } from '../../scripts/check-doc-metrics.mjs';
+
+describe('stripHistoricalSections', () => {
+ it('blanks a Keep-a-Changelog-style `## [x.y.z]` section', () => {
+ const md = [
+ '## [1.24.0] — 2026-06-21',
+ '',
+ 'Shipped with 17 locales.',
+ '',
+ '## Current state',
+ '',
+ 'Ships 19 locales.',
+ ].join('\n');
+ const stripped = stripHistoricalSections(md);
+ expect(stripped).not.toContain('17 locales');
+ expect(stripped).toContain('19 locales');
+ });
+
+ it('blanks a `## vX.Y.Z … RELEASED …` section', () => {
+ const md = [
+ '## v1.23.0 — Rebrand (RELEASED 2026-06-16)',
+ '',
+ 'Shipped with 11 locales.',
+ '',
+ '## Upcoming',
+ '',
+ 'Targeting 19 locales.',
+ ].join('\n');
+ const stripped = stripHistoricalSections(md);
+ expect(stripped).not.toContain('11 locales');
+ expect(stripped).toContain('19 locales');
+ });
+
+ it('does NOT exclude a present-tense heading that merely mentions a version in prose', () => {
+ const md = ['## Current status', '', 'As of v1.24.1, the app ships 17 locales.'].join('\n');
+ const stripped = stripHistoricalSections(md);
+ expect(stripped).toContain('17 locales');
+ });
+
+ it('re-enables scanning once a non-historical heading follows a historical one', () => {
+ const md = [
+ '## [1.23.0]',
+ 'Historical: 11 locales.',
+ '## Roadmap',
+ 'Present: 17 locales.',
+ '## [1.24.0]',
+ 'Historical again: 11 locales.',
+ ].join('\n');
+ const stripped = stripHistoricalSections(md);
+ expect(stripped).not.toMatch(/Historical/);
+ expect(stripped).toContain('Present: 17 locales.');
+ });
+});
+
+describe('scanForDrift', () => {
+ const actual = { localeCount: 19, keyCount: 2849, latestVersion: '1.24.1' };
+
+ it('flags a present-tense locale-count mismatch', () => {
+ const content = 'WorldScript Studio ships **17 locales**.';
+ const findings = scanForDrift(content, 'FAKE.md', actual);
+ expect(findings.some((f) => f.includes('17 locale'))).toBe(true);
+ });
+
+ it('does not flag a matching locale count', () => {
+ const content = 'WorldScript Studio ships **19 locales**.';
+ const findings = scanForDrift(content, 'FAKE.md', actual);
+ expect(findings).toHaveLength(0);
+ });
+
+ it('does not flag a locale-count mismatch inside a historical section', () => {
+ const content = ['## [1.23.0]', '', 'Shipped 11 locales.'].join('\n');
+ const findings = scanForDrift(content, 'FAKE.md', actual);
+ expect(findings).toHaveLength(0);
+ });
+
+ it('flags a key-count mismatch', () => {
+ const content = 'Shipped UI locales with **2844 i18n keys**.';
+ const findings = scanForDrift(content, 'FAKE.md', actual);
+ expect(findings.some((f) => f.includes('2844'))).toBe(true);
+ });
+
+ it('flags a stale PLANNED marker for an already-released version', () => {
+ const content = '## Upcoming — v1.24 (PLANNED)';
+ const findings = scanForDrift(content, 'FAKE.md', actual);
+ expect(findings.some((f) => f.includes('PLANNED'))).toBe(true);
+ });
+
+ it('does not flag a PLANNED marker for a version newer than the latest release', () => {
+ const content = '## Upcoming — v2.0 (PLANNED)';
+ const findings = scanForDrift(content, 'FAKE.md', actual);
+ expect(findings).toHaveLength(0);
+ });
+});
diff --git a/token-audit-baseline.json b/token-audit-baseline.json
index 0faeffc8..2714b37f 100644
--- a/token-audit-baseline.json
+++ b/token-audit-baseline.json
@@ -1,5 +1,5 @@
{
- "total": 165,
+ "total": 160,
"summary": {
"raw-hex": 45,
"raw-rgb": 1,
@@ -8,5 +8,5 @@
"hardcoded-shadow": 0,
"inline-svg": 114
},
- "updatedAt": "2026-06-14T21:30:00.000Z"
+ "updatedAt": "2026-07-28T19:47:24.932Z"
}
diff --git a/vitest.config.ts b/vitest.config.ts
index 362aed24..16910b3e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -39,7 +39,9 @@ export default defineConfig({
reporters: ['default', ['junit', { outputFile: 'reports/junit.xml' }]],
coverage: {
provider: 'v8',
- reporter: ['text', 'lcov', 'html'],
+ // QNBS-v3: json-summary produces coverage/coverage-summary.json, consumed by
+ // scripts/check-coverage-ratchet.mjs (non-blocking CI step suggesting when to ratchet up).
+ reporter: ['text', 'lcov', 'html', 'json-summary'],
include: [
'app/**/*.{ts,tsx}',
'components/**/*.{ts,tsx}',