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 @@ Codecov Coverage License MIT CI Status - Codecov Coverage Lighthouse Score Launch live on GitHub Pages - - CII Best Practices

--- 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 @@ <img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8"> <img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0"> <img src="https://img.shields.io/badge/i18n-19_locales-2859_keys-0EA5E9" alt="i18n 19 locales — 2859 keys"> - <img src="https://img.shields.io/badge/Tests-5807%2B_%2F_527_files-22C55E" alt="5807+ tests / 527 files"> + <img src="https://img.shields.io/badge/Tests-5807%2B_%2F_528_files-22C55E" alt="5807+ tests / 528 files"> <img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage"> <img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT"> <img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status"> @@ -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 @@ <img src="https://img.shields.io/badge/Version-v1.24.1-6366F1" alt="v1.24.1"> <img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8"> <img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0"> - <img src="https://img.shields.io/badge/i18n-19_locales-2859_keys-0EA5E9" alt="i18n 19 locales — 2859 keys"> + <img src="https://img.shields.io/badge/i18n-19_locales-2849_keys-0EA5E9" alt="i18n 19 locales — 2849 keys"> <img src="https://img.shields.io/badge/Tests-5807%2B_%2F_528_files-22C55E" alt="5807+ tests / 528 files"> <img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage"> <img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT"> @@ -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';