diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..1f43a4a0 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces (issues, pull +requests, discussions, and any other project-hosted forum), and also applies +when an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported via **[GitHub Private Vulnerability Reporting](https://github.com/qnbs/WorldScript-Studio/security/advisories/new)** +or by opening a private conversation with a maintainer through GitHub. This +mirrors the reporting channel documented in [`SECURITY.md`](SECURITY.md) — +there is no separate public issue tracker for conduct reports, to keep the +report private by default. + +All complaints will be reviewed and investigated promptly and fairly. All +community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of +Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels. +Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla coc]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla coc]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9304aa3c..039dc453 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Code of Conduct -This project follows the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). +This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). ## How to Contribute @@ -24,4 +24,4 @@ See [`docs/CI.md`](../docs/CI.md) for full CI documentation. ## Security -See [`SECURITY.md`](../SECURITY.md) for vulnerability reporting. \ No newline at end of file +See [`SECURITY.md`](SECURITY.md) for vulnerability reporting. \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 443a14ef..763cf559 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -32,7 +32,7 @@ contexts/ → React context providers (one per major view + I18nContext features/ → Redux Toolkit slices: project, settings, status, writer, versionControl, featureFlags hooks/ → Custom hooks with view business logic (one hook per view) services/ → External adapters: geminiService, aiProviderService, dbService (dual IndexedDB + migration), storageService, collaborationService; **ai/** (aiModeService — execution modes, aiPolicy, aiRetry; **providers/** — openrouterProvider with circuit breaker); **copilot/** (heuristicEngine 8 rules, insightGenerator, copilotContextService, actionApplier); **commands/** (palette registry); **keyboard/** (shortcut matching); **help/** (doc retrieval for AI); **settingsExchange** (settings JSON) -locales/ → i18n source files — de/en/es/fr/it (core) + ar/he/fa (RTL Beta) + el/ja/pt/zh/fi/sv/hu/is/eu (Beta) × ~20 JSON modules (17 locales; see the README badge for the live key count) +locales/ → i18n source files — de/en/es/fr/it (core) + ar/he/fa (RTL Beta) + el/ja/pt/zh/fi/sv/hu/is/eu/ru/ko (Beta) × 21 JSON modules (19 locales; see the README badge for the live key count) public/locales/ → i18n runtime files served at BASE_URL tests/ → Unit + E2E tests (Vitest + Playwright) types/ → Additional TypeScript type definitions @@ -144,7 +144,7 @@ types.ts → Core shared interfaces and types - All user-facing strings must use `t('key.path')` from `useTranslation()` - Source files: `locales/{lang}/{module}.json` (15 modules). Runtime: **one** merged **`public/locales/{lang}/bundle.json`** per language — regenerate with **`pnpm run i18n:bundle`** or **`pnpm run i18n:check`** (parity check **and** bundle build); **`predev`** / **`prebuild`** also rebuild bundles so the UI never shows raw keys after editing locale JSON. -- **17 locales ship** (de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu Beta); all must keep key parity with English (`pnpm run i18n:check` in CI). The `/i18n-key` skill auto-fills the **5 core** (de/en/es/fr/it); update Beta/RTL locales manually afterward. +- **19 locales ship** (de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu/ru/ko Beta); all must keep key parity with English (`pnpm run i18n:check` in CI). The `/i18n-key` skill auto-fills the **5 core** (de/en/es/fr/it); update Beta/RTL locales manually afterward. - English is the fallback language - New keys: add to **`locales/en/`** first, then **de**, **fr**, **es**, **it** (or run `node scripts/check-i18n-keys.mjs --fix` and translate), then commit updated **`bundle.json`** files diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3cec063..777912a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,9 +99,12 @@ jobs: # QNBS-v3: Explicit versions — `node` (= 'current') breaks CI without code changes when Node N+1 is released. node-version: ['22', '24'] steps: + # QNBS-v3: fetch-tags — check-doc-metrics.mjs's stale-PLANNED-status check reads `git tag` + # and silently no-ops without one; a default shallow checkout has no tags at all. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false + fetch-tags: true - uses: ./.github/actions/setup with: node-version: ${{ matrix.node-version }} @@ -118,6 +121,9 @@ jobs: - name: i18n key parity (vs en) run: pnpm run i18n:check + - name: Doc metrics drift gate (locale/key counts, stale PLANNED status) + run: pnpm run docs:check + - name: Feature parity audit run: pnpm run parity:check @@ -136,6 +142,11 @@ jobs: NODE_OPTIONS: "--no-experimental-webstorage --max-old-space-size=4096" CI: "true" + - name: Coverage ratchet check (informational, non-blocking) + if: always() + continue-on-error: true + run: node scripts/check-coverage-ratchet.mjs + - name: Upload Vitest JSON results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8839632..2f585e46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,7 @@ Thank you for your interest in contributing to WorldScript Studio — an AI-powe ## Table of Contents +- [Code of Conduct](#code-of-conduct) - [Development Setup](#development-setup) - [Tech Stack](#tech-stack) - [Project Structure](#project-structure) @@ -17,6 +18,10 @@ Thank you for your interest in contributing to WorldScript Studio — an AI-powe --- +## Code of Conduct + +This project follows the [Contributor Covenant Code of Conduct](.github/CODE_OF_CONDUCT.md). By participating, you are expected to uphold it — report unacceptable behavior via [GitHub Private Vulnerability Reporting](https://github.com/qnbs/WorldScript-Studio/security/advisories/new), the same private channel documented in [`.github/SECURITY.md`](.github/SECURITY.md). + ## Development Setup ### Prerequisites @@ -122,7 +127,7 @@ WorldScript-Studio/ ├── contexts/ # React contexts per view + I18n + CommandExecutor ├── features/ # Redux slices (project, settings, writer, status, featureFlags, plotBoard, progressTracker, sceneComments) ├── hooks/ # Custom React hooks per view + shared hooks (e.g. useGlobalKeyboardShortcuts) -├── locales/ # i18n source JSON — 17 locales: de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu Beta (key parity vs en) +├── locales/ # i18n source JSON — 19 locales: de/en/es/fr/it core + ar/he/fa RTL + el/ja/pt/zh/fi/sv/hu/is/eu/ru/ko Beta (key parity vs en) ├── services/ # Adapters: AI, DB, storage, collaboration, EPUB; commands/, keyboard/, help/, settingsExchange ├── stories/ # Storybook stories for UI components ├── docs/ # Deep-dive docs (CI reference, history, graphify) diff --git a/README.md b/README.md index 9395ebff..208b9c44 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,13 @@ v1.24.1 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2859 keys - 5807+ tests / 527 files + i18n 19 locales — 2849 keys + 5807+ tests / 528 files Codecov Coverage License MIT CI Status - Codecov Coverage Lighthouse Score Launch live on GitHub Pages - - CII Best Practices

--- @@ -351,7 +348,7 @@ One-click encrypted export of your entire project library from **Settings → Da ### 🌐 Full Multi-Language Support -Shipped UI locales with **2859 i18n keys** across all 19 languages — zero hardcoded user-facing strings: +Shipped UI locales with **2849 i18n keys** across all 19 languages — zero hardcoded user-facing strings: - 🇩🇪 **German** (Deutsch) - 🇬🇧 **English** @@ -460,8 +457,8 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **PDF Export** | jsPDF | Client-side, configurable PDF document generation | | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | -| **i18n** | Custom React Context (`I18nContext.tsx`) | 2859 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (5807+ tests / 527 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **i18n** | Custom React Context (`I18nContext.tsx`) | 2849 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence | +| **Testing** | Vitest 4.x (5807+ tests / 528 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -492,13 +489,13 @@ WorldScript-Studio/ │ ├── keyboard/ # Shortcut matching, OS normalization, conflict detection │ ├── help/ # RAG-lite retrieval for in-app help articles │ └── settingsExchange/ # Zod-validated settings import/export -├── locales/ # i18n source JSON (de/en/es/fr/it × 15 modules each) +├── locales/ # i18n source JSON (19 locales × 21 modules each) ├── public/ │ ├── locales/ # i18n runtime bundles (rebuilt by pnpm run i18n:check / prebuild) │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (5807+ tests, 527 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (5807+ tests, 528 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -657,9 +654,9 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-07-28):** -- **5807+ unit tests** across **527 test files** — all passing +- **5807+ unit tests** across **528 test files** — all passing - Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics) -- i18n: **2859 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) +- i18n: **2849 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) **CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist. diff --git a/ROADMAP.md b/ROADMAP.md index bb9290a0..ab5a0fb7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,11 +22,11 @@ Benchmarks from the UI/PWA deep-dive (implemented in repo, no new mandatory docs --- -## Upcoming — v1.24 / v2.0 Foundation (PLANNED) +## Upcoming — v2.0 Foundation (PLANNED) -Forward-looking work carried out of the v1.23 cycle: +Forward-looking work carried out of the v1.24 cycle (i18n expansion and the six-PR hardening +sequence below both shipped in v1.24.0/v1.24.1 — see the RELEASED sections): -- **i18n — 17 locales (✅ bulk-translated):** added `fi/sv/hu/is/eu` (Beta) + `fa` (RTL, Persian). Priority chrome hand-translated; **all remaining modules machine-translated** (glossary v2.0, placeholder-masked) to ~96-100 % across all 10 Beta locales. Remaining: **human native review** (checklist in [`docs/TRANSLATION-GUIDE.md`](docs/TRANSLATION-GUIDE.md)). See [`docs/LANGUAGE-EXPANSION-2026.md`](docs/LANGUAGE-EXPANSION-2026.md). - **Coverage targets:** lift toward L≥85 % / B≥75 % / F≥80 % — focus on AI routing, Voice, Copilot. - **Local AI & Voice hardening:** Whisper/Kokoro on low-end devices, model-integrity checks, Eco-Mode. - **Error boundaries + structured logging** for AI/worker failures. @@ -35,6 +35,29 @@ Forward-looking work carried out of the v1.23 cycle: - **Plugin Registry Beta:** graduate from the sandbox-hardening phase. - **Bundle optimisation:** further code-splitting for heavy chunks. - **Local-first:** advance ADR-0008 — B2.1 Y.Text migration → B2.2 SoT flip behind `enableLocalFirstSync`. +- **i18n human native review:** 14 Beta/RTL locales (all except de/en/es/fr/it) are machine-translated to ~96-100 %; native-speaker review still open (checklist in [`docs/TRANSLATION-GUIDE.md`](docs/TRANSLATION-GUIDE.md)). See [`docs/LANGUAGE-EXPANSION-2026.md`](docs/LANGUAGE-EXPANSION-2026.md). + +--- + +## v1.24.1 — Local-AI Reliability, Heuristic Fallbacks, LanguageTool (RELEASED 2026-07-28) + +**Status:** ✅ Released — see [`CHANGELOG.md`](CHANGELOG.md) `[1.24.1]`. + +- Local-AI reliability: desktop Ollama/LM Studio/vLLM discovery fixes, corrected a misleading browser AI-status badge. +- AI heuristic-fallback foundation (ADR-0011) + per-feature generators for Outline, Character, World profiles, and Plot Board "suggest next beat" — offline-capable degradation when cloud/local AI is unavailable. +- Real self-hosted LanguageTool grammar/spelling integration (privacy-first, replaces a placeholder typo list). +- 20-PR Dependabot backlog triage (16 merged, 4 closed as superseded after root-causing AI SDK v4/Biome 2.5.x/dev-tooling blockers). +- Issue #60 vendor-fork audit closeout (`packages/collab-transport` vs. upstream y-webrtc 10.3.0). +- Security/build hardening: `persist-credentials` (CWE-522), a Tailwind `ltr`/`rtl` fix, 3 ineffective dynamic imports, 2 CWE-209 fixes. + +--- + +## v1.24.0 — Critical Hardening + 17→19 Locale Expansion (RELEASED 2026-06-21) + +**Status:** ✅ Released — see [`CHANGELOG.md`](CHANGELOG.md) `[1.24.0]`. + +- Six stacked PRs: privacy, experimental-feature labeling, coverage, voice consent, local-AI, and doc/hygiene hardening. +- i18n expansion: added `fi/sv/hu/is/eu` (Beta) + `fa` (RTL, Persian) — 11→17 locales at release time; `ru`/`ko` followed in a later v1.24.x patch, bringing the current total to **19**. Priority chrome hand-translated; remaining modules machine-translated (glossary v2.0, placeholder-masked) to ~96-100 % across all Beta locales. --- diff --git a/TODO.md b/TODO.md index 9d65de47..d02d680a 100644 --- a/TODO.md +++ b/TODO.md @@ -15,6 +15,7 @@ Status: 🔄 in progress | ⬜ open | ✅ done - ✅ **Feature catalog reconciled** — `featureCatalog.ts` covers all 23 flags; `defaultOn` derived from the slice (drift now impossible, guarded by `tests/unit/featureCatalog.test.ts`); added risk/desktop/dependency metadata. - ✅ **Grouped Settings UI** — Experimental flags grouped by category with risk hints, dependency-aware disabling (Voice WASM ⇠ Voice Support), "Desktop only" note (Rust Compute), and Reset-to-defaults. - ⬜ **WebNN flag decision** — `enableWebnnInference` is a ghost/stub (no runtime gate reads it). Either wire `selectEnableWebnnInference` into the ONNX RT WebNN provider selection, or retire the flag. +- ⬜ **CII Best Practices badge** — removed from `README.md` (was a literal `projects/XXXX` placeholder — broken image, dead link). Register the project at (external, interactive signup — maintainer-only action), then re-add the badge with the real project ID. ## v1.23 — P0 Audit Follow-up (DELIVERED 2026-06-16) diff --git a/docs/CI.md b/docs/CI.md index 3d246163..f836ba1e 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -15,7 +15,7 @@ For historical optimization notes (targets may predate the live workflow), see [ | Tier | Where | Commands / scope | |------|--------|------------------| | **Quick (local)** | Developer laptop | `pnpm run lint`, `pnpm run typecheck`, `pnpm run i18n:check`; optional `pnpm exec vitest run` **without** `--coverage` for a fast smoke | -| **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Stryker (informational), Storybook static build, bundle budget + analyze | +| **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Storybook static build, bundle budget + analyze. Mutation testing (Stryker) is **not** part of this pipeline — see [Mutation testing status](#mutation-testing-status). | **Merge readiness:** A green workflow run on the PR/branch matters more than reproducing every E2E or LHCI step locally. Use CI **artifacts** (Playwright HTML report, coverage, Lighthouse output) to debug failures. @@ -73,20 +73,20 @@ Each job that uses the composite must call `actions/checkout@v6` first (local co ```text security ──► quality ──┬──► build ──┬──► lighthouse ├──► e2e └──► vrt - ├──► storybook - └──► mutation (Stryker; informational score gate) + └──► storybook build (main, non-PR) ──► upload-pages-artifact deploy (main, non-PR) needs: build + e2e ──► GitHub Pages ``` +Mutation testing (Stryker) is **not** in this graph — it runs only via manual `workflow_dispatch` on [`mutation.yml`](../.github/workflows/mutation.yml). See [Mutation testing status](#mutation-testing-status). + | Job | Needs | Purpose | |-----|--------|---------| | `security` | — | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` | -| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run parity:check`**, `tsc`, Vitest + coverage, Codecov (optional token), coverage artifact | +| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov (optional token), coverage artifact | | `build` | `quality` | Production `pnpm run build`, **`bundle:budget`**, **`analyze`** (upload `bundle-analysis.html`), `dist` artifact; on `main` (non-PR): Pages artifact + **SLSA build provenance attestation** | | `e2e` | `quality` | Playwright **Chromium** + **Mobile Chrome** (Pixel 5) — `CI=true`, 2× retries, 50 min timeout; browser cache via `actions/cache@v5`. Firefox optional locally. `PLAYWRIGHT_SKIP_VRT=true` (VRT is its own job). | -| `mutation` | `quality` | **`pnpm run mutation`** if `stryker.conf.json` exists; **`break: 75`** → score <75% fails the job (timeout at 20 min is expected on shared runners — not a blocker for deploy) | | `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. | | `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--max-workers=2 --retries=3 --screenshot-on-failure`; artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | | `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium); artifacts uploaded always | @@ -96,6 +96,28 @@ deploy (main, non-PR) needs: build + e2e ──► GitHub Pages --- +## Mutation testing status + +Stryker was removed from the `quality` job on **2026-06-02** — it ran as a flaky, non-gating check +that added noise to every PR without blocking anything (see the `QNBS-v3` comment above the `build` +job in [`ci.yml`](../.github/workflows/ci.yml)). It now runs **only** via manual trigger: + +```bash +gh workflow run mutation.yml +``` + +`mutation.yml` is `workflow_dispatch`-only — it never runs automatically on push or PR. This means +the mutation score is **not continuously tracked**; it's a point-in-time snapshot whenever someone +runs it manually. [`stryker.conf.json`](../stryker.conf.json) still defines the thresholds +(`break: 75`, `high: 85`, `low: 70`) that would apply if it ran. + +**Re-integration criterion:** bring it back into the `quality` job (or a separate required check) +once a manual run demonstrates the flakiness is resolved — concretely, three consecutive manual +`workflow_dispatch` runs on `main` completing without a spurious failure or timeout, at the current +40-target mutate scope. Until then, treat a manual run's score as informational only, not a gate. + +--- + ## Supply-chain security | Tool | Trigger | Output | diff --git a/docs/CODE_QUALITY.md b/docs/CODE_QUALITY.md index 4f898b39..a866fbdf 100644 --- a/docs/CODE_QUALITY.md +++ b/docs/CODE_QUALITY.md @@ -53,6 +53,22 @@ ref.current.innerHTML = DOMPurify.sanitize(html); - When you refactor code that allows removing a suppression, remove it and run `node scripts/check-suppressions.mjs --update` to ratchet the baseline down. - The CI gate prints a per-rule breakdown; use `node scripts/check-suppressions.mjs --details` for a per-file breakdown. +### Suppression Reduction Plan + +The baseline sits at **52/52 — no headroom**; any new suppression fails the gate outright. `lint/suspicious/noExplicitAny` is the largest single rule (33 of 52, across 20 test files — all in `tests/unit/`, none in application code). Grouped by cluster: + +Cluster counts below are from `node scripts/check-suppressions.mjs --details`, not estimates — they sum to exactly 52. + +| Cluster | Files | Count | Effort | +|---|---|---|---| +| Voice/WASM browser-API mocking | `tests/unit/voice/wasmSttEngine.test.ts` (7), `tests/unit/voice/sttEngine.test.ts` (4) | 11 | High — these mock `MediaRecorder`/constructor-style globals and reach into private test internals; a properly-typed mock would need real `MediaRecorder`/Worker type shims, not a quick fix. | +| `useAppSelector`/`useAppSelectorShallow` mock casts | `tests/unit/SceneBoardView.test.tsx` (2); `BackupQuickActionsCard.test.tsx`, `ConnectionToolbar.test.tsx`, `ReferencePanelView.test.tsx`, `hooks/useProForgeOrchestrator.test.ts`, `hooks/useSceneBoardView.test.ts`, `settings/ProjectAiPresetSection.test.tsx` (1 each) — 7 files per the existing convention note in [`tests/CLAUDE.md`](../tests/CLAUDE.md) | 8 | Medium — `tests/CLAUDE.md` already identifies the fix: one shared, properly-typed helper (`(selector: (s: RootState) => unknown) => selector(mockState as RootState)`) replacing all 8 call sites removes this whole cluster in one dedicated PR. | +| Voice service test mocks | `tests/unit/services/voice/voiceDownloadAndIntent.test.ts` (2), `voiceCommandService.test.ts` (2), `voiceActivityCoordinator.test.ts` (1) | 5 | Medium — similar shape to the browser-API cluster above, smaller scope per file. | +| Single-occurrence hook/thunk mocks | `characterInterviewTemplates.test.ts` (2); `ManuscriptView.test.tsx`, `cloudSyncBackend.test.ts`, `hooks/useAdaptiveAi.test.ts`, `hooks/usePlotBoardAi.test.ts`, `lora/loraThunks.test.ts`, `manuscript/NavigatorPanel.test.tsx`, `thunks/interviewThunks.test.ts` (1 each) | 9 | Low-Medium — each is an isolated mock-typing gap; fix opportunistically when touching that test file rather than as a dedicated sweep. | +| Remaining rules (`useExhaustiveDependencies` 6, `useSemanticElements` 5, `noDangerouslySetInnerHtml` 2, `useArrowFunction` 2, `noThenProperty` 1, `useAriaPropsSupportedByRole` 1, `noEmptyPattern` 1, `noConstructorReturn` 1) | Scattered across `App.tsx`, `components/`, `hooks/` | 19 | Low — each is a one-off, case-by-case judgment call already documented inline at the suppression site. | + +No code changes in this pass — this section exists purely to make the debt visible instead of it silently accumulating one suppression at a time, the state it was in before this table existed. The actual paydown is separate, future work. + --- ## Security-sensitive coding patterns diff --git a/docs/TRANSLATION-GUIDE.md b/docs/TRANSLATION-GUIDE.md index 0c1a10a7..77c4d618 100644 --- a/docs/TRANSLATION-GUIDE.md +++ b/docs/TRANSLATION-GUIDE.md @@ -4,8 +4,8 @@ > terminology reference in [`I18N-GLOSSARY.md`](I18N-GLOSSARY.md) and the rollout notes in > [`LANGUAGE-EXPANSION-2026.md`](LANGUAGE-EXPANSION-2026.md). -WorldScript Studio ships **17 locales**: `de` `en` `es` `fr` `it` (core, fully translated) · -`ar` `he` `fa` (RTL Beta) · `ja` `zh` `pt` `el` `fi` `sv` `hu` `is` `eu` (Beta). Beta locales are +WorldScript Studio ships **19 locales**: `de` `en` `es` `fr` `it` (core, fully translated) · +`ar` `he` `fa` (RTL Beta) · `ja` `zh` `pt` `el` `fi` `sv` `hu` `is` `eu` `ru` `ko` (Beta). Beta locales are machine-translated (glossary-anchored) and await human native review. --- @@ -14,12 +14,12 @@ machine-translated (glossary-anchored) and await human native review. - **No i18next.** A custom React context (`contexts/I18nContext.tsx`) is the runtime. Components read strings via `const { t } = useTranslation()` and call `t('module.key.path')`. -- **Source of truth:** `locales//.json` — **20 modules** (`common`, `settings`, - `writer`, `manuscript`, `copilot`, `dashboard`, `outline`, `worlds`, `characters`, `export`, - `help`, `tour`, `portal`, `templates`, `sidebar`, `tags`, `objects`, `mindmap`, `lora`, +- **Source of truth:** `locales//.json` — **21 modules** (`common`, `settings`, + `writer`, `manuscript`, `copilot`, `dashboard`, `desktop`, `outline`, `worlds`, `characters`, + `export`, `help`, `tour`, `portal`, `templates`, `sidebar`, `tags`, `objects`, `mindmap`, `lora`, `characterInterviews`). Files are **flat JSON** — keys are the full dotted path (`"portal.welcome.title": "…"`), not nested objects. -- **Runtime bundles:** `pnpm run i18n:bundle` (or `i18n:check`) merges the 20 module files per +- **Runtime bundles:** `pnpm run i18n:bundle` (or `i18n:check`) merges the 21 module files per language into `public/locales//bundle.json`. **Never hand-edit a bundle** — edit the source module and rebuild. - **English is the reference.** Every locale must hold the exact same key set as `locales/en` @@ -28,7 +28,7 @@ machine-translated (glossary-anchored) and await human native review. ### Commands ```bash -pnpm run i18n:check # key parity across all 17 locales + rebuild bundles + content guard +pnpm run i18n:check # key parity across all 19 locales + rebuild bundles + content guard pnpm run i18n:bundle # rebuild public/locales//bundle.json only node scripts/check-i18n-keys.mjs --quality # report likely-untranslated (EN-identical) strings per locale node scripts/check-i18n-keys.mjs --fix # fill MISSING keys in EXISTING files with the EN value (does NOT create files) diff --git a/docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md b/docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md index 0264ef72..5297d2ce 100644 --- a/docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md +++ b/docs/i18n/AUDIT_AND_IMPROVEMENT_PLAN.md @@ -7,7 +7,7 @@ ## 1. Current state (as audited) -- **17 locales × 21 modules**, ~2793 keys, full **key parity** enforced by +- **19 locales × 21 modules**, ~2849 keys, full **key parity** enforced by `scripts/check-i18n-keys.mjs` (CI `quality` job) + bundled to `public/locales//bundle.json`. - Runtime: custom `contexts/I18nContext.tsx` — lazy `bundle.json` fetch, `localStorage` persistence (`worldscript-language`), English fallback chain, cached `Intl.*` helpers, `Intl.Segmenter` word diff --git a/package.json b/package.json index 84f3f97c..e10dbd3c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "i18n:status": "tsx scripts/i18n-status-dashboard.ts", "content:guard": "node scripts/content-guard.mjs", "parity:check": "tsx scripts/audit-feature-parity.ts", + "docs:check": "node scripts/check-doc-metrics.mjs", "suppressions:check": "node scripts/check-suppressions.mjs", "token:audit": "node scripts/audit-tokens.mjs", "sync:readme": "node scripts/sync-readme-metrics.mjs", diff --git a/scripts/check-coverage-ratchet.mjs b/scripts/check-coverage-ratchet.mjs new file mode 100644 index 00000000..0debf479 --- /dev/null +++ b/scripts/check-coverage-ratchet.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Non-blocking: compares coverage/coverage-summary.json (produced by `pnpm run test:coverage`, + * the json-summary reporter in vitest.config.ts) against the configured coverage thresholds, and + * prints a suggestion when a metric sits comfortably above its threshold. The ratchet-up decision + * stays with the maintainer (vitest.config.ts's own comment documents a "3 consecutive green runs, + * max 5 points/quarter" rule) — this script only makes the opportunity visible instead of it + * quietly rotting, the way the threshold history shows it has before. + * + * Run: node scripts/check-coverage-ratchet.mjs + * Always exits 0 — never gates CI; wire it as a non-blocking informational step. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(fileURLToPath(new URL('.', import.meta.url)), '..'); +const SUMMARY_PATH = join(root, 'coverage', 'coverage-summary.json'); + +// QNBS-v3: mirrors vitest.config.ts's `coverage.thresholds` — that file isn't a plain-data module +// (it's a defineConfig() call), so this can't import it directly. Keep both in sync by hand; a +// mismatch here only makes a suggestion's "vs. threshold" number wrong, it can't mask a real +// coverage regression (vitest.config.ts's own thresholds still gate the actual build). +const THRESHOLDS = { lines: 74, functions: 67, branches: 60, statements: 72 }; +const RATCHET_GAP = 2; + +// QNBS-v3: every exit path is a `return`, never `process.exit(1)` — this is advisory-only, so a missing/malformed/all-clean summary must all fall through to the same non-blocking outcome. +function main() { + if (!existsSync(SUMMARY_PATH)) { + process.stdout.write( + '[coverage-ratchet] No coverage/coverage-summary.json found — run `pnpm run test:coverage` first. Skipping (non-blocking).\n', + ); + return; + } + + // QNBS-v3: a truncated/invalid summary must not violate the "always exits 0" contract this + // script documents — catch parse failures the same way a missing file is already handled. + let summary; + try { + summary = JSON.parse(readFileSync(SUMMARY_PATH, 'utf8')); + } catch { + process.stdout.write( + '[coverage-ratchet] Could not parse coverage-summary.json — skipping (non-blocking).\n', + ); + return; + } + const total = summary.total; + if (!total) { + process.stdout.write( + '[coverage-ratchet] coverage-summary.json has no "total" field — skipping.\n', + ); + return; + } + + const suggestions = []; + for (const [metric, threshold] of Object.entries(THRESHOLDS)) { + const pct = total[metric]?.pct; + if (typeof pct !== 'number') continue; + if (pct - threshold >= RATCHET_GAP) { + suggestions.push( + `${metric} ${pct.toFixed(2)} vs. threshold ${threshold} → consider raising to ${Math.floor(pct) - 1}`, + ); + } + } + + if (suggestions.length === 0) { + process.stdout.write( + `[coverage-ratchet] No threshold has ${RATCHET_GAP}+ points of headroom — nothing to ratchet yet.\n`, + ); + return; + } + + process.stdout.write( + `[coverage-ratchet] Ratchet fällig — see docs/COVERAGE-POLICY.md before raising:\n${suggestions + .map((s) => ` - ${s}`) + .join('\n')}\n`, + ); +} + +main(); diff --git a/scripts/check-doc-metrics.d.mts b/scripts/check-doc-metrics.d.mts new file mode 100644 index 00000000..7bc76b4e --- /dev/null +++ b/scripts/check-doc-metrics.d.mts @@ -0,0 +1,9 @@ +export function stripHistoricalSections(markdown: string): string; +export function getActualLocaleCount(): number; +export function getActualKeyCount(): number; +export function getLatestReleasedVersion(): string | null; +export function scanForDrift( + content: string, + filePath: string, + actual: { localeCount: number; keyCount: number; latestVersion: string | null }, +): string[]; diff --git a/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs new file mode 100644 index 00000000..ace9c6b6 --- /dev/null +++ b/scripts/check-doc-metrics.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * Fails if a doc file states a locale/key count or a release-status claim that no longer matches + * reality — the drift this audit found repeatedly (ROADMAP.md/TRANSLATION-GUIDE.md/TODO.md/ + * CONTRIBUTING.md/.github/copilot-instructions.md all said "17 locales" after the 17→19 expansion). + * Historical/dated entries (CHANGELOG-style `## [x.y.z]` headings, or `## vX.Y.Z … RELEASED …` + * section headings) are exempt — they correctly describe a past state, not the present one. + * + * Run: node scripts/check-doc-metrics.mjs + */ +import { execSync } from 'node:child_process'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getModules, REF_LANG } from './i18n-locales.mjs'; + +const root = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + +// QNBS-v3: every file the header comment names as a past drift site must actually be scanned — +// CodeRabbit caught .github/copilot-instructions.md missing here, which would let that exact +// regression recur silently past this gate. +const TARGET_FILES = [ + 'README.md', + 'ROADMAP.md', + 'TODO.md', + 'docs/TRANSLATION-GUIDE.md', + 'CONTRIBUTING.md', + '.github/CONTRIBUTING.md', + '.github/copilot-instructions.md', +]; + +// QNBS-v3: this repo marks "done" several different ways — Keep-a-Changelog `## [x.y.z]`, +// `## vX.Y.Z … RELEASED …`, a heading suffixed with ✅ (`### Phase 3A … ✅`), a `**Status:** ✅ +// Released/Completed …` line just below a plain `## vX.Y — ` heading, or a dated heading +// `(YYYY-MM-DD)` — a section carrying ANY of these near its top is a historical snapshot, exempt +// from present-tense metric checks. Independently, this repo's own TODO.md legend ("✅ done") means +// any individual `- ✅ …` bullet is historical regardless of which section it sits in. +const ANY_HEADING = /^#{1,6}\s+/; +const HISTORICAL_MARKER = + /(✅|\bRELEASED\b|\bDELIVERED\b|\bCompleted\b|\[\d+\.\d+\.\d+\]|\(\d{4}-\d{2}-\d{2})/i; +const DONE_BULLET = /^\s*-\s*✅/; +// QNBS-v3: how many lines below a heading to look for a "**Status:** ✅ Released …" marker that +// applies to the whole section (this repo puts it on its own line, not in the heading text). +const STATUS_LOOKAHEAD = 5; + +/** + * Blank out lines that fall in a historical section (see above), keeping line numbers stable so + * findings still point at the right place in the ORIGINAL file for anything that isn't blanked. + */ +export function stripHistoricalSections(markdown) { + const lines = markdown.split('\n'); + const headingIdx = []; + lines.forEach((line, i) => { + if (ANY_HEADING.test(line)) headingIdx.push(i); + }); + headingIdx.push(lines.length); // sentinel so the last section has an end bound + + const historical = new Array(lines.length).fill(false); + for (let s = 0; s < headingIdx.length - 1; s++) { + const start = headingIdx[s]; + const end = headingIdx[s + 1]; + const lookahead = lines.slice(start, Math.min(end, start + STATUS_LOOKAHEAD)); + if (lookahead.some((l) => HISTORICAL_MARKER.test(l))) { + for (let i = start; i < end; i++) historical[i] = true; + } + } + + return lines.map((line, i) => (historical[i] || DONE_BULLET.test(line) ? '' : line)).join('\n'); +} + +/** Actual locale count = directories under locales/ (translation-glossary.json is a file, not a locale). */ +// QNBS-v3: reads the filesystem directly rather than a hand-maintained constant, so a new locale is picked up automatically instead of going stale like the doc claims this gate exists to catch. +export function getActualLocaleCount() { + return readdirSync(join(root, 'locales'), { withFileTypes: true }).filter((e) => e.isDirectory()) + .length; +} + +/** Actual key count = deduplicated key set across all modules for the reference locale (matches check-i18n-keys.mjs). */ +// QNBS-v3: Set-based dedup, not a per-file sum — a key shared by two modules must count once, the exact bug this gate caught in sync-readme-metrics.mjs's own counting logic. +export function getActualKeyCount() { + const keys = new Set(); + for (const mod of getModules()) { + const p = join(root, 'locales', REF_LANG, `${mod}.json`); + const data = JSON.parse(readFileSync(p, 'utf8')); + for (const k of Object.keys(data)) keys.add(k); + } + return keys.size; +} + +/** Latest released version tag (e.g. "1.24.1"), or null if no tags exist (e.g. a shallow clone). */ +// QNBS-v3: null (not a thrown error) on a shallow/tagless checkout — callers must treat "no tags" as "skip the PLANNED check", never as a drift finding of its own. +export function getLatestReleasedVersion() { + try { + const tag = execSync('git tag --sort=-v:refname', { cwd: root, encoding: 'utf8' }) + .split('\n') + .find((t) => t.trim().length > 0); + return tag ? tag.trim().replace(/^v/, '') : null; + } catch { + return null; + } +} + +function semverLte(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0); + } + return true; // equal +} + +/** + * Scan one file's (historical-stripped) content for locale-count / key-count / stale-PLANNED + * drift against the actual computed values. Returns human-readable finding strings. + */ +// QNBS-v3: regex-scans prose for numeric claims rather than requiring structured metadata — this gate exists precisely because docs drift in free-form text, not in a machine-checked field. +export function scanForDrift(content, filePath, { localeCount, keyCount, latestVersion }) { + const findings = []; + const scanned = stripHistoricalSections(content); + const lines = scanned.split('\n'); + + lines.forEach((line, i) => { + for (const m of line.matchAll(/(\d+)\s+locales?\b/gi)) { + const found = Number(m[1]); + if (found !== localeCount) { + findings.push( + `${filePath}:${i + 1} — says "${found} locale(s)", actual is ${localeCount}: "${line.trim()}"`, + ); + } + } + for (const m of line.matchAll(/(\d+)\s+(?:i18n\s+)?keys\b/gi)) { + const found = Number(m[1]); + if (found !== keyCount) { + findings.push( + `${filePath}:${i + 1} — says "${found} keys", actual is ${keyCount}: "${line.trim()}"`, + ); + } + } + if (latestVersion) { + for (const m of line.matchAll(/v?(\d+\.\d+(?:\.\d+)?)[^\n]*\bPLANNED\b/gi)) { + const mentioned = m[1]; + if (semverLte(mentioned, latestVersion)) { + findings.push( + `${filePath}:${i + 1} — "v${mentioned} … PLANNED" but v${mentioned} <= latest released v${latestVersion}: "${line.trim()}"`, + ); + } + } + } + }); + + return findings; +} + +// QNBS-v3: exits 1 on any finding — unlike check-coverage-ratchet.mjs this gate is blocking, since a doc claiming a wrong locale/key/release count is actively misleading, not just an opportunity. +function main() { + const localeCount = getActualLocaleCount(); + const keyCount = getActualKeyCount(); + const latestVersion = getLatestReleasedVersion(); + + const allFindings = []; + for (const relPath of TARGET_FILES) { + const abs = join(root, relPath); + let content; + try { + content = readFileSync(abs, 'utf8'); + } catch { + continue; // file doesn't exist in this checkout — not this gate's concern + } + allFindings.push(...scanForDrift(content, relPath, { localeCount, keyCount, latestVersion })); + } + + if (allFindings.length > 0) { + process.stderr.write( + `[docs:check] DOC METRICS DRIFT — ${allFindings.length} finding(s):\n${allFindings + .map((f) => ` - ${f}`) + .join('\n')}\n`, + ); + process.exit(1); + } + + process.stdout.write( + `[docs:check] OK — ${TARGET_FILES.length} files match actual state (${localeCount} locales, ${keyCount} keys${ + latestVersion ? `, latest v${latestVersion}` : '' + }).\n`, + ); +} + +// QNBS-v3: only run the CLI side-effect when invoked directly — stripHistoricalSections/ +// scanForDrift/getActual* stay importable (and independently testable) from a unit test. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/sync-readme-metrics.mjs b/scripts/sync-readme-metrics.mjs index adb7a17e..414d61fc 100644 --- a/scripts/sync-readme-metrics.mjs +++ b/scripts/sync-readme-metrics.mjs @@ -45,14 +45,6 @@ function countTestFiles(dir) { return count; } -/** Count leaf values in a nested JSON object. */ -function countLeaves(obj) { - return Object.values(obj).reduce( - (acc, v) => acc + (v && typeof v === 'object' ? countLeaves(v) : 1), - 0, - ); -} - function getTestFileCount() { // Mirror the three vitest.config.ts include roots; tests/e2e is excluded inside countTestFiles. return ( @@ -64,14 +56,19 @@ function getTestFileCount() { ); } +// QNBS-v3: Set-based dedup across module files — a key defined in two files (e.g. a shared +// key living in both settings.json and its canonical home module) must count once, matching +// check-i18n-keys.mjs's loadBundleKeys(). A per-file leaf sum double-counted such keys, which is +// exactly what caused this badge to drift from the real, deduplicated i18n:check count. function getKeyCount() { const localeDir = join(root, 'locales', 'en'); - let total = 0; + const keys = new Set(); for (const file of readdirSync(localeDir)) { if (!file.endsWith('.json')) continue; - total += countLeaves(JSON.parse(readFileSync(join(localeDir, file), 'utf8'))); + const data = JSON.parse(readFileSync(join(localeDir, file), 'utf8')); + for (const k of Object.keys(data)) keys.add(k); } - return total; + return keys.size; } // QNBS-v3: locale count is the number of per-language directories under locales/ (each ships a diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts new file mode 100644 index 00000000..63c7a602 --- /dev/null +++ b/tests/unit/checkDocMetrics.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +/** + * Tests for scripts/check-doc-metrics.mjs + * QNBS-v3: protects the drift gate from historical-section regressions — an untested exclusion heuristic would turn it into noise. + */ +import { describe, expect, it } from 'vitest'; +import { scanForDrift, stripHistoricalSections } from '../../scripts/check-doc-metrics.mjs'; + +describe('stripHistoricalSections', () => { + it('blanks a Keep-a-Changelog-style `## [x.y.z]` section', () => { + const md = [ + '## [1.24.0] — 2026-06-21', + '', + 'Shipped with 17 locales.', + '', + '## Current state', + '', + 'Ships 19 locales.', + ].join('\n'); + const stripped = stripHistoricalSections(md); + expect(stripped).not.toContain('17 locales'); + expect(stripped).toContain('19 locales'); + }); + + it('blanks a `## vX.Y.Z … RELEASED …` section', () => { + const md = [ + '## v1.23.0 — Rebrand (RELEASED 2026-06-16)', + '', + 'Shipped with 11 locales.', + '', + '## Upcoming', + '', + 'Targeting 19 locales.', + ].join('\n'); + const stripped = stripHistoricalSections(md); + expect(stripped).not.toContain('11 locales'); + expect(stripped).toContain('19 locales'); + }); + + it('does NOT exclude a present-tense heading that merely mentions a version in prose', () => { + const md = ['## Current status', '', 'As of v1.24.1, the app ships 17 locales.'].join('\n'); + const stripped = stripHistoricalSections(md); + expect(stripped).toContain('17 locales'); + }); + + it('re-enables scanning once a non-historical heading follows a historical one', () => { + const md = [ + '## [1.23.0]', + 'Historical: 11 locales.', + '## Roadmap', + 'Present: 17 locales.', + '## [1.24.0]', + 'Historical again: 11 locales.', + ].join('\n'); + const stripped = stripHistoricalSections(md); + expect(stripped).not.toMatch(/Historical/); + expect(stripped).toContain('Present: 17 locales.'); + }); +}); + +describe('scanForDrift', () => { + const actual = { localeCount: 19, keyCount: 2849, latestVersion: '1.24.1' }; + + it('flags a present-tense locale-count mismatch', () => { + const content = 'WorldScript Studio ships **17 locales**.'; + const findings = scanForDrift(content, 'FAKE.md', actual); + expect(findings.some((f) => f.includes('17 locale'))).toBe(true); + }); + + it('does not flag a matching locale count', () => { + const content = 'WorldScript Studio ships **19 locales**.'; + const findings = scanForDrift(content, 'FAKE.md', actual); + expect(findings).toHaveLength(0); + }); + + it('does not flag a locale-count mismatch inside a historical section', () => { + const content = ['## [1.23.0]', '', 'Shipped 11 locales.'].join('\n'); + const findings = scanForDrift(content, 'FAKE.md', actual); + expect(findings).toHaveLength(0); + }); + + it('flags a key-count mismatch', () => { + const content = 'Shipped UI locales with **2844 i18n keys**.'; + const findings = scanForDrift(content, 'FAKE.md', actual); + expect(findings.some((f) => f.includes('2844'))).toBe(true); + }); + + it('flags a stale PLANNED marker for an already-released version', () => { + const content = '## Upcoming — v1.24 (PLANNED)'; + const findings = scanForDrift(content, 'FAKE.md', actual); + expect(findings.some((f) => f.includes('PLANNED'))).toBe(true); + }); + + it('does not flag a PLANNED marker for a version newer than the latest release', () => { + const content = '## Upcoming — v2.0 (PLANNED)'; + const findings = scanForDrift(content, 'FAKE.md', actual); + expect(findings).toHaveLength(0); + }); +}); diff --git a/token-audit-baseline.json b/token-audit-baseline.json index 0faeffc8..2714b37f 100644 --- a/token-audit-baseline.json +++ b/token-audit-baseline.json @@ -1,5 +1,5 @@ { - "total": 165, + "total": 160, "summary": { "raw-hex": 45, "raw-rgb": 1, @@ -8,5 +8,5 @@ "hardcoded-shadow": 0, "inline-svg": 114 }, - "updatedAt": "2026-06-14T21:30:00.000Z" + "updatedAt": "2026-07-28T19:47:24.932Z" } diff --git a/vitest.config.ts b/vitest.config.ts index 362aed24..16910b3e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,7 +39,9 @@ export default defineConfig({ reporters: ['default', ['junit', { outputFile: 'reports/junit.xml' }]], coverage: { provider: 'v8', - reporter: ['text', 'lcov', 'html'], + // QNBS-v3: json-summary produces coverage/coverage-summary.json, consumed by + // scripts/check-coverage-ratchet.mjs (non-blocking CI step suggesting when to ratchet up). + reporter: ['text', 'lcov', 'html', 'json-summary'], include: [ 'app/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}',