From 5315dc8ff4d6eeeb657b8b9686d6e044b4e2459f 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/3] 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 6a7b6b6229553ea0f401d87b30f382854a5c3583 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/3] 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 045fbf56..faa68497 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 aadad0e18b861113c21901e508e887f4c1d312b0 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/3] 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 faa68497..b93b972c 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}',