From 9ab09e046725ae318f7315f3bd9548d81f505f86 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:05:03 +0200
Subject: [PATCH 1/6] chore(repo): fix README badges, add Code of Conduct, link
it from both CONTRIBUTING files
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- README.md: removed the duplicate codecov badge (kept the more informative
`&label=Coverage` one) and the CII Best Practices badge, which had a literal
`projects/XXXX` placeholder (broken image, dead link) — the project was
never registered. Added a TODO.md entry with the registration URL; that's
an external, interactive signup, so it stays a maintainer action, not
something this pass does automatically.
- Added .github/CODE_OF_CONDUCT.md (Contributor Covenant 2.1), with an
enforcement-contact section that mirrors SECURITY.md's pattern (GitHub
Private Vulnerability Reporting, no hardcoded personal email).
- .github/CONTRIBUTING.md already referenced a Code of Conduct but linked
externally to contributor-covenant.org instead of the new in-repo file —
repointed it.
- The root CONTRIBUTING.md — the one README.md actually links to — had no
Code of Conduct section at all. Added one, linking to the new file.
Investigated (no code changes, per the workstream's stop-and-ask boundaries):
- The v1.5.0 GitHub release sits chronologically between v1.20.0 and v1.21.0
despite its lower semver, AND its 6 assets are all named
"StoryCraft.Studio_1.20.0_..." — the project's pre-rebrand name (rebrand to
WorldScript Studio shipped in v1.23.0) with a version that doesn't match the
release tag at all. Flagged for the final report; deleting/retitling a
public release is a maintainer decision.
- Issue #60 (vendor-fork audit, "permanent reminder — do not close") already
has a 2026-07-28 maintainer comment documenting the audit as done, and
`pnpm run verify:vendor` passes all 12 invariants. No action needed — it's
correctly staying open per its own instruction.
---
.github/CODE_OF_CONDUCT.md | 132 +++++++++++++++++++++++++++++++++++++
.github/CONTRIBUTING.md | 2 +-
CONTRIBUTING.md | 5 ++
README.md | 3 -
TODO.md | 1 +
5 files changed, 139 insertions(+), 4 deletions(-)
create mode 100644 .github/CODE_OF_CONDUCT.md
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..4b2bd654 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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a8839632..aacd9516 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
diff --git a/README.md b/README.md
index 9395ebff..ec2d652e 100644
--- a/README.md
+++ b/README.md
@@ -17,11 +17,8 @@
-
-
-
---
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)
From 558716f70b7962e7edab673a766d2ce3a5ed0ed4 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:06:53 +0200
Subject: [PATCH 2/6] chore(docs): truth-up locale counts + release status, add
doc-metrics drift gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ROADMAP.md still marked v1.24 as "(PLANNED)" though v1.24.0 (2026-06-21) and
v1.24.1 (2026-07-28) had both already shipped, and gave the locale count as
17 — stale since the 11->17 expansion (v1.24.0) plus ru/ko (landed the next
day, before v1.24.1). Same "17 locales" drift was independently present in
docs/TRANSLATION-GUIDE.md (also missing 1 of 21 modules — desktop.json — from
its module list and count), the root CONTRIBUTING.md (the one README.md
actually links to), .github/copilot-instructions.md (2 occurrences), and
docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md's "current state" section. Fixed all
of them; left every CHANGELOG.md and dated AUDIT.md/TODO.md historical entry
untouched — they correctly describe what was true at a past release.
Added scripts/check-doc-metrics.mjs (wired into ci.yml's quality job as
`pnpm run docs:check`) so this class of drift fails CI instead of silently
accumulating again. It computes the actual locale count, key count (dedup'd
the same way check-i18n-keys.mjs does), and latest released version tag, then
scans README/ROADMAP/TODO/TRANSLATION-GUIDE/CONTRIBUTING for stale numbers or
a "vX.Y.Z ... PLANNED" claim for an already-released version.
Historical sections are exempt (Keep-a-Changelog `## [x.y.z]`, `## vX.Y.Z ...
RELEASED ...`, a heading suffixed with an emoji checkmark, a `**Status:** ✅
Released/Completed` line just below a heading, or this repo's own TODO.md "✅
= done" bullet convention) — the first version only recognized the
Keep-a-Changelog/RELEASED-heading shapes and produced 26 false positives
against TODO.md/ROADMAP.md's other historical conventions; broadened it
before wiring the gate in. tests/unit/checkDocMetrics.test.ts covers the
exclusion heuristic directly (the plan's explicit requirement — an untested
exclusion rule turns a drift gate into noise).
---
.github/copilot-instructions.md | 4 +-
.github/workflows/ci.yml | 3 +
CONTRIBUTING.md | 2 +-
ROADMAP.md | 29 +++-
docs/TRANSLATION-GUIDE.md | 14 +-
docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md | 2 +-
package.json | 1 +
scripts/check-doc-metrics.d.mts | 9 ++
scripts/check-doc-metrics.mjs | 182 ++++++++++++++++++++++++
tests/unit/checkDocMetrics.test.ts | 101 +++++++++++++
10 files changed, 333 insertions(+), 14 deletions(-)
create mode 100644 scripts/check-doc-metrics.d.mts
create mode 100644 scripts/check-doc-metrics.mjs
create mode 100644 tests/unit/checkDocMetrics.test.ts
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..63a63119 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -118,6 +118,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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index aacd9516..2f585e46 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -127,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/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/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-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..96554d2a
--- /dev/null
+++ b/scripts/check-doc-metrics.mjs
@@ -0,0 +1,182 @@
+#!/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)), '..');
+
+const TARGET_FILES = [
+ 'README.md',
+ 'ROADMAP.md',
+ 'TODO.md',
+ 'docs/TRANSLATION-GUIDE.md',
+ 'CONTRIBUTING.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). */
+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). */
+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). */
+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.
+ */
+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;
+}
+
+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/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts
new file mode 100644
index 00000000..40f1d249
--- /dev/null
+++ b/tests/unit/checkDocMetrics.test.ts
@@ -0,0 +1,101 @@
+// @vitest-environment node
+/**
+ * Tests for scripts/check-doc-metrics.mjs
+ * QNBS-v3: the historical-section exclusion heuristic is the part most likely to silently break
+ * (an untested exclusion rule turns the drift gate into noise) — this file's primary job is
+ * proving it actually excludes a dated snapshot and does NOT excuse a present-tense claim.
+ */
+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);
+ });
+});
From 1e233c1d5fb7c12721af304a597d489e2b0301a3 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 28 Jul 2026 22:11:10 +0200
Subject: [PATCH 3/6] chore(gates): document coverage-ratchet tooling,
suppression-debt plan, mutation-testing status
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Re-pinned token-audit-baseline.json to the actual measured total (160,
down from a stale 165 dated 2026-06-14) after WS-1..6 landed. Ratchets
down only, per the project's hard rule against loosening a quality gate.
- Added scripts/check-coverage-ratchet.mjs: reads coverage/coverage-summary.json
(added the `json-summary` reporter to vitest.config.ts — it wasn't being
generated at all) and suggests raising a threshold when the measured value
sits 2+ points above it, matching the "3 green runs, max 5 pts/quarter"
rule already documented as a comment in vitest.config.ts but never
mechanized. Wired into ci.yml as `continue-on-error: true` — informational
only, never blocks the build; the ratchet decision stays with the
maintainer.
- docs/CODE_QUALITY.md: added a Suppression Reduction Plan grouping all 33
noExplicitAny suppressions (of 52 total, at an exact-match baseline with
no headroom) into clusters by root cause and effort — no code changes,
visibility only.
- docs/CI.md: corrected three places that still described Stryker mutation
testing as part of the active `ci.yml` job graph (job-graph diagram, the
`mutation` job's table row, and the CI-tier table) — it was removed from
the merge pipeline on 2026-06-02 and now runs only via manual
`workflow_dispatch` on mutation.yml. Documented why, how to trigger it,
and the re-integration criterion.
---
.github/workflows/ci.yml | 5 +++
docs/CI.md | 32 +++++++++++---
docs/CODE_QUALITY.md | 14 ++++++
scripts/check-coverage-ratchet.mjs | 69 ++++++++++++++++++++++++++++++
token-audit-baseline.json | 4 +-
vitest.config.ts | 4 +-
6 files changed, 120 insertions(+), 8 deletions(-)
create mode 100644 scripts/check-coverage-ratchet.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 63a63119..d5de152e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -139,6 +139,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/docs/CI.md b/docs/CI.md
index 3d246163..2619d2eb 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`**, `tsc`, 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..cb5c3109 100644
--- a/docs/CODE_QUALITY.md
+++ b/docs/CODE_QUALITY.md
@@ -53,6 +53,20 @@ 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 | 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 | 10 files per the existing convention note in [`tests/CLAUDE.md`](../tests/CLAUDE.md) (e.g. `tests/unit/SceneBoardView.test.tsx`) | ~10 | Medium — `tests/CLAUDE.md` already identifies the fix: one shared, properly-typed helper (`(selector: (s: RootState) => unknown) => selector(mockState as RootState)`) replacing all 10 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 | `useSceneBoardView`, `useProForgeOrchestrator`, `usePlotBoardAi`, `useAdaptiveAi`, `interviewThunks`, `loraThunks` tests, etc. (1 each) | ~8 | 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/scripts/check-coverage-ratchet.mjs b/scripts/check-coverage-ratchet.mjs
new file mode 100644
index 00000000..b1732d29
--- /dev/null
+++ b/scripts/check-coverage-ratchet.mjs
@@ -0,0 +1,69 @@
+#!/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;
+
+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;
+ }
+
+ const summary = JSON.parse(readFileSync(SUMMARY_PATH, 'utf8'));
+ 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/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}',
From e48ffcb2c5e11ac01f4036a056d9cd4eafd1667b Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Wed, 29 Jul 2026 01:13:38 +0200
Subject: [PATCH 4/6] =?UTF-8?q?docs:=20resync=20README=20test-file-count?=
=?UTF-8?q?=20badge=20(527=E2=86=92528)=20after=20rebase=20onto=20new=20ma?=
=?UTF-8?q?in?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index ec2d652e..52f116bb 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -458,7 +458,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`) | 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) |
+| **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` |
@@ -495,7 +495,7 @@ WorldScript-Studio/
│ ├── 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
@@ -654,7 +654,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-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)
From 6b5067578af473d27d819798995ee2c7dd8bdd4d Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Wed, 29 Jul 2026 01:22:57 +0200
Subject: [PATCH 5/6] fix(scripts): dedupe sync-readme-metrics.mjs's i18n key
count across module files
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The new docs:check drift gate (this same branch) immediately caught a real
bug on its first CI run: README claimed 2859 i18n keys while the actual
deduplicated count is 2849. Root cause is in sync-readme-metrics.mjs itself,
not just the (already-fixed-elsewhere) duplicate source keys — getKeyCount()
summed leaf counts per module file independently, so any key defined in two
files (as 10 keys were, until the PR #281 dedup fix) got counted twice. This
is a latent bug: it will silently drift again the moment any future PR
reintroduces a cross-module duplicate, regardless of today's specific fix.
Changed getKeyCount() to collect keys into a Set across all module files,
matching check-i18n-keys.mjs's loadBundleKeys() dedup logic exactly — the two
scripts can no longer disagree about the canonical key count.
---
README.md | 8 ++++----
scripts/sync-readme-metrics.mjs | 19 ++++++++-----------
2 files changed, 12 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 52f116bb..cb640b58 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -348,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**
@@ -457,7 +457,7 @@ 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 |
+| **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 |
@@ -656,7 +656,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
**Current test metrics (2026-07-28):**
- **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/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
From f89922f166e9dda6061a371dcb7b39d30518cedb Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Wed, 29 Jul 2026 02:13:40 +0200
Subject: [PATCH 6/6] fix: address CodeRabbit review wave on PR #283
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Real bugs found and fixed:
- .github/CONTRIBUTING.md linked SECURITY.md as ../SECURITY.md, but that file
lives at .github/SECURITY.md — a sibling of CONTRIBUTING.md, not one level
up. The link resolved to a nonexistent repo-root file. CodeRabbit anchored
this finding on .github/CODE_OF_CONDUCT.md, whose own (correct) sibling
link I verified matches; the actual broken link was in CONTRIBUTING.md.
- .github/workflows/ci.yml's quality-job checkout never fetched tags, so
check-doc-metrics.mjs's stale-PLANNED-status check (which reads `git tag`)
silently no-oped in every CI run. Added fetch-tags: true.
- check-coverage-ratchet.mjs's JSON.parse had no try/catch — a truncated or
invalid coverage-summary.json would throw uncaught, contradicting the
script's own documented "always exits 0, never gates CI" contract. Now
falls through to the same non-blocking skip message as a missing file.
- check-doc-metrics.mjs's TARGET_FILES list didn't include
.github/copilot-instructions.md or .github/CONTRIBUTING.md, despite the
script's own header comment naming both as files that had the exact stale-
locale-count regression this gate exists to catch — a recurrence there
would have passed docs:check silently.
- docs/CODE_QUALITY.md's suppression cluster table summed to ~53 against the
stated 52/52 baseline. Recomputed exact per-cluster counts from
`check-suppressions.mjs --details` (11 + 8 + 5 + 9 + 19 = 52) and corrected
the useAppSelector/single-occurrence cluster file lists to match.
- README.md's project-tree comment still said "15 modules"; the doc-truth-up
work elsewhere in this PR already established 19 locales × 21 modules.
- docs/CI.md's pipeline table said `tsc` where the actual quality-job step
(and package.json) both use `pnpm run typecheck`.
Convention-only:
- Added the missing single-line QNBS-v3 rationale comments to
check-doc-metrics.mjs (filesystem reads, tag lookup, drift scan, CLI
orchestration) and check-coverage-ratchet.mjs (non-blocking contract).
- Collapsed checkDocMetrics.test.ts's multiline file-header rationale to one
line.
---
.github/CONTRIBUTING.md | 2 +-
.github/workflows/ci.yml | 3 +++
README.md | 2 +-
docs/CI.md | 2 +-
docs/CODE_QUALITY.md | 6 ++++--
scripts/check-coverage-ratchet.mjs | 13 ++++++++++++-
scripts/check-doc-metrics.mjs | 10 ++++++++++
tests/unit/checkDocMetrics.test.ts | 4 +---
8 files changed, 33 insertions(+), 9 deletions(-)
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 4b2bd654..039dc453 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -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/workflows/ci.yml b/.github/workflows/ci.yml
index d5de152e..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 }}
diff --git a/README.md b/README.md
index cb640b58..208b9c44 100644
--- a/README.md
+++ b/README.md
@@ -489,7 +489,7 @@ 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
diff --git a/docs/CI.md b/docs/CI.md
index 2619d2eb..f836ba1e 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -84,7 +84,7 @@ Mutation testing (Stryker) is **not** in this graph — it runs only via manual
| 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 docs:check`**, **`pnpm run parity:check`**, `tsc`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), 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). |
| `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. |
diff --git a/docs/CODE_QUALITY.md b/docs/CODE_QUALITY.md
index cb5c3109..a866fbdf 100644
--- a/docs/CODE_QUALITY.md
+++ b/docs/CODE_QUALITY.md
@@ -57,12 +57,14 @@ ref.current.innerHTML = DOMPurify.sanitize(html);
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 | 10 files per the existing convention note in [`tests/CLAUDE.md`](../tests/CLAUDE.md) (e.g. `tests/unit/SceneBoardView.test.tsx`) | ~10 | Medium — `tests/CLAUDE.md` already identifies the fix: one shared, properly-typed helper (`(selector: (s: RootState) => unknown) => selector(mockState as RootState)`) replacing all 10 call sites removes this whole cluster in one dedicated PR. |
+| `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 | `useSceneBoardView`, `useProForgeOrchestrator`, `usePlotBoardAi`, `useAdaptiveAi`, `interviewThunks`, `loraThunks` tests, etc. (1 each) | ~8 | Low-Medium — each is an isolated mock-typing gap; fix opportunistically when touching that test file rather than as a dedicated sweep. |
+| 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.
diff --git a/scripts/check-coverage-ratchet.mjs b/scripts/check-coverage-ratchet.mjs
index b1732d29..0debf479 100644
--- a/scripts/check-coverage-ratchet.mjs
+++ b/scripts/check-coverage-ratchet.mjs
@@ -24,6 +24,7 @@ const SUMMARY_PATH = join(root, 'coverage', 'coverage-summary.json');
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(
@@ -32,7 +33,17 @@ function main() {
return;
}
- const summary = JSON.parse(readFileSync(SUMMARY_PATH, 'utf8'));
+ // 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(
diff --git a/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs
index 96554d2a..ace9c6b6 100644
--- a/scripts/check-doc-metrics.mjs
+++ b/scripts/check-doc-metrics.mjs
@@ -16,12 +16,17 @@ 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]`,
@@ -64,12 +69,14 @@ export function stripHistoricalSections(markdown) {
}
/** 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()) {
@@ -81,6 +88,7 @@ export function getActualKeyCount() {
}
/** 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' })
@@ -105,6 +113,7 @@ function semverLte(a, b) {
* 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);
@@ -142,6 +151,7 @@ export function scanForDrift(content, filePath, { localeCount, keyCount, latestV
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();
diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts
index 40f1d249..63c7a602 100644
--- a/tests/unit/checkDocMetrics.test.ts
+++ b/tests/unit/checkDocMetrics.test.ts
@@ -1,9 +1,7 @@
// @vitest-environment node
/**
* Tests for scripts/check-doc-metrics.mjs
- * QNBS-v3: the historical-section exclusion heuristic is the part most likely to silently break
- * (an untested exclusion rule turns the drift gate into noise) — this file's primary job is
- * proving it actually excludes a dated snapshot and does NOT excuse a present-tense claim.
+ * 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';