From cf50e4dd6d2f80a63179848cad302426e3ebfef0 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:05:55 +0200 Subject: [PATCH 1/9] fix(ci): require pre-merge CHANGELOG PR-reference for governed changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/check-doc-metrics.mjs's completeness gate only enforces a PR-number reference in CHANGELOG.md's [Unreleased] section AFTER squash-merge, once the commit is on main and its subject already carries "(#N)" — pre-merge, a branch's own not-yet-squashed commits are (correctly) exempted from that check. This has left a recurring blind spot: nothing stops a governed PR from merging without ever adding the entry, even though its real PR number is already knowable via the GitHub API before merge. It has recurred three times (#678->#679, #684->#685, #699->#700), each requiring a same-pattern follow-up PR to add the missing reference after the fact. Adds a new, independent pre-merge admission gate (.github/workflows/pr-changelog-reference.yml + scripts/check-pr-changelog-reference.mjs) that fails a governed (feat|fix| perf) PR's CI unless CHANGELOG.md's [Unreleased] section already references it as "PR #", using the PR number from GitHub's own event payload — not inferred from commit history. Deliberately stricter grammar than the existing post-merge bare "#NNN" matcher, since pre-merge there is no squash-appended "(#NNN)" to anchor on. Mirrors pr-text-attribution.yml's base-ref self-grading pattern (runs the checker from the PR's base ref, with a documented one-time bootstrap fallback) so a PR cannot weaken the check that grades it. The existing scanUnreleasedTruth machinery in check-doc-metrics.mjs — governing local pre-push behavior and the historical post-merge/branch-local exemption — is untouched. Complements, but does not implement, issue #675's broader deterministic- identifier-contract scope (replacing the unnumbered-commit slug-match fallback) — this gate only closes the narrower pre-merge admission gap for PRs that already have a real, known PR number, which is the common case. 13 regression tests plus real-text fixtures reproducing all three historical incidents (#678/#679, #684/#685, #699/#700) in tests/unit/checkPrChangelogReference.test.ts. --- .github/workflows/pr-changelog-reference.yml | 31 ++++ README.md | 8 +- scripts/check-pr-changelog-reference.d.mts | 18 ++ scripts/check-pr-changelog-reference.mjs | 100 ++++++++++ tests/unit/checkPrChangelogReference.test.ts | 183 +++++++++++++++++++ 5 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/pr-changelog-reference.yml create mode 100644 scripts/check-pr-changelog-reference.d.mts create mode 100644 scripts/check-pr-changelog-reference.mjs create mode 100644 tests/unit/checkPrChangelogReference.test.ts diff --git a/.github/workflows/pr-changelog-reference.yml b/.github/workflows/pr-changelog-reference.yml new file mode 100644 index 000000000..f7f4ce987 --- /dev/null +++ b/.github/workflows/pr-changelog-reference.yml @@ -0,0 +1,31 @@ +name: PR CHANGELOG Reference Guard +on: + pull_request: + types: [opened, edited, synchronize, reopened] + branches: [main] +permissions: + contents: read +jobs: + check: + name: Require this PR's own number in CHANGELOG.md [Unreleased] before merge + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Require [Unreleased] to reference this PR before merge + env: + GITHUB_EVENT_PATH: ${{ github.event_path }} + run: | + set -euo pipefail + BASE="${{ github.event.pull_request.base.sha }}" + mkdir -p /tmp/base-scripts + CHECKER=scripts/check-pr-changelog-reference.mjs + if git show "$BASE:scripts/check-pr-changelog-reference.mjs" > /tmp/base-scripts/check-pr-changelog-reference.mjs 2>/dev/null; then + CHECKER=/tmp/base-scripts/check-pr-changelog-reference.mjs + else + echo "::notice::check-pr-changelog-reference.mjs not found on base ref (bootstrap PR) — using this PR's own copy this one time." + fi + node "$CHECKER" diff --git a/README.md b/README.md index 690e90e89..4402712b3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7651+ tests / 604 files + 7668+ tests / 605 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7651+ tests / 604 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7668+ tests / 605 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` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7651+ tests, 604 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7668+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**. **Current test metrics (2026-09-10, source-synchronized; CI remains authoritative for pass/fail):** -- **7651+ unit tests** across **604 test files** — CI is authoritative for pass/fail +- **7668+ unit tests** across **605 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/scripts/check-pr-changelog-reference.d.mts b/scripts/check-pr-changelog-reference.d.mts new file mode 100644 index 000000000..3f81a0070 --- /dev/null +++ b/scripts/check-pr-changelog-reference.d.mts @@ -0,0 +1,18 @@ +export function isReferencedByPrLabel(prNumber: number, unreleasedSection: string): boolean; + +export interface CheckPrChangelogReferenceInput { + prNumber: number; + prTitle: string | undefined | null; + changelog: string | undefined | null; +} + +export type CheckPrChangelogReferenceReason = 'not-governed' | 'referenced' | 'missing-reference'; + +export interface CheckPrChangelogReferenceResult { + ok: boolean; + reason: CheckPrChangelogReferenceReason; +} + +export function checkPrChangelogReference( + input: CheckPrChangelogReferenceInput, +): CheckPrChangelogReferenceResult; diff --git a/scripts/check-pr-changelog-reference.mjs b/scripts/check-pr-changelog-reference.mjs new file mode 100644 index 000000000..b96c227e7 --- /dev/null +++ b/scripts/check-pr-changelog-reference.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * CI-only pre-merge admission gate: before a governed (feat|fix|perf) PR can merge, its own + * CHANGELOG.md [Unreleased] section must already reference this PR's real GitHub-assigned number + * as "PR #". This closes the blind spot where scripts/check-doc-metrics.mjs's completeness + * check only fires AFTER squash-merge, once the commit is on main and its subject already carries + * "(#N)" — a gap that has recurred three times (#678->#679, #684->#685, #699->#700), each requiring + * a same-pattern follow-up PR to add the missing reference after the fact. + * + * Deliberately self-contained (no local imports, mirrors check-commit-attribution.mjs) so the + * base-ref self-grading copy in .github/workflows/pr-changelog-reference.yml never breaks on a + * missing transitive dependency (check-doc-metrics.mjs itself imports two further local modules + * that would also need copying and keeping in sync). + */ +import { readFileSync } from 'node:fs'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +// QNBS-v3: duplicated from check-doc-metrics.mjs's GOVERNED_COMMIT_TYPE (kept in sync manually, not via import) so this file has zero local dependencies — see file header. +const GOVERNED_COMMIT_TYPE = /^(?:feat|fix|perf)(?:\([^)]*\))?!?:\s*/i; + +// QNBS-v3: duplicated from check-doc-metrics.mjs's getUnreleasedSectionText for the same self-containment reason. +function getUnreleasedSectionText(changelog) { + const heading = /^## \[Unreleased\]\s*$/m.exec(changelog); + if (!heading) return ''; + const afterHeading = changelog.slice(heading.index + heading[0].length); + const nextHeading = afterHeading.search(/^##\s/m); + const section = nextHeading === -1 ? afterHeading : afterHeading.slice(0, nextHeading); + return section.replace(/|$)/g, ''); +} + +// QNBS-v3: exact "PR #NNN" grammar, stricter than check-doc-metrics.mjs's post-merge bare "#NNN" matcher — pre-merge there is no squash-appended "(#NNN)" to anchor on, so a bare "#NNN" could belong to an unrelated issue/PR mention instead of a genuine self-reference. +export function isReferencedByPrLabel(prNumber, unreleasedSection) { + return new RegExp(`\\bPR\\s*#${prNumber}(?!\\d)`, 'i').test(unreleasedSection); +} + +/** Pure decision function — kept separate from I/O so it is directly unit-testable. */ +export function checkPrChangelogReference({ prNumber, prTitle, changelog }) { + if (!GOVERNED_COMMIT_TYPE.test(prTitle ?? '')) { + return { ok: true, reason: 'not-governed' }; + } + const unreleasedSection = getUnreleasedSectionText(changelog ?? ''); + return isReferencedByPrLabel(prNumber, unreleasedSection) + ? { ok: true, reason: 'referenced' } + : { ok: false, reason: 'missing-reference' }; +} + +function main() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) { + console.log('[check-pr-changelog-reference] no GITHUB_EVENT_PATH — skipping'); + process.exit(0); + } + + let payload; + try { + payload = JSON.parse(readFileSync(eventPath, 'utf8')); + } catch (error) { + console.error( + `[check-pr-changelog-reference] cannot read event payload: ${error instanceof Error ? error.message : 'invalid JSON'}`, + ); + process.exit(1); + } + + const pr = payload.pull_request; + if (!pr || typeof pr.number !== 'number') { + console.log('[check-pr-changelog-reference] not a pull_request event — skipping'); + process.exit(0); + } + + let changelog; + try { + changelog = readFileSync('CHANGELOG.md', 'utf8'); + } catch (error) { + console.error( + `[check-pr-changelog-reference] cannot read CHANGELOG.md: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + + const result = checkPrChangelogReference({ prNumber: pr.number, prTitle: pr.title, changelog }); + if (result.reason === 'not-governed') { + console.log( + '[check-pr-changelog-reference] PR title is not a governed feat/fix/perf change — skipping', + ); + process.exit(0); + } + if (!result.ok) { + console.error( + `[check-pr-changelog-reference] FAIL — CHANGELOG.md's [Unreleased] section does not yet reference "PR #${pr.number}". Add (or update) a bullet describing this change and reference it literally as "PR #${pr.number}" before merging.`, + ); + process.exit(1); + } + console.log(`[check-pr-changelog-reference] OK — [Unreleased] references PR #${pr.number}`); +} + +// QNBS-v3: only run the CLI side-effect when invoked directly — checkPrChangelogReference stays importable from a unit test. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/tests/unit/checkPrChangelogReference.test.ts b/tests/unit/checkPrChangelogReference.test.ts new file mode 100644 index 000000000..1b66578b9 --- /dev/null +++ b/tests/unit/checkPrChangelogReference.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment node +/** + * Tests for scripts/check-pr-changelog-reference.mjs — the pre-merge admission gate closing the + * blind spot where scripts/check-doc-metrics.mjs's completeness check only fires AFTER squash-merge + * (recurred 3x: #678->#679, #684->#685, #699->#700). + */ +import { describe, expect, it } from 'vitest'; +import { + checkPrChangelogReference, + isReferencedByPrLabel, +} from '../../scripts/check-pr-changelog-reference.mjs'; + +const UNRELEASED = (body: string) => `## [Unreleased]\n\n### Fixed\n\n${body}\n\n## [1.28.6]\n`; + +describe('checkPrChangelogReference', () => { + it('1. accepts a governed "feat:" PR whose Unreleased entry cites "PR #"', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'feat(writer): add outline templates', + changelog: UNRELEASED('- **Outline templates:** adds starter templates. PR #700.'), + }); + expect(result).toEqual({ ok: true, reason: 'referenced' }); + }); + + it('2. accepts a governed "fix:" PR whose Unreleased entry cites "PR #"', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'fix(pwa): gate cache activation on precache success', + changelog: UNRELEASED('- **PWA precache admission:** hardens activation. PR #700.'), + }); + expect(result.ok).toBe(true); + }); + + it('3. accepts a governed "perf:" PR whose Unreleased entry cites "PR #"', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'perf(rag): batch embedding lookups', + changelog: UNRELEASED('- **RAG batching:** reduces lookup overhead. PR #700.'), + }); + expect(result.ok).toBe(true); + }); + + it('4. accepts a scoped governed title, e.g. "fix(pwa): ..."', () => { + const result = checkPrChangelogReference({ + prNumber: 525, + prTitle: 'fix(pwa): gate cache-generation activation on precache success', + changelog: UNRELEASED('- **SW admission gate:** described here. PR #525.'), + }); + expect(result.ok).toBe(true); + }); + + it('5. accepts a breaking-change "!" governed title, e.g. "feat(api)!: ..."', () => { + const result = checkPrChangelogReference({ + prNumber: 812, + prTitle: 'feat(api)!: drop legacy v1 provider adapter', + changelog: UNRELEASED('- **Legacy provider removal:** drops v1 adapter. PR #812.'), + }); + expect(result.ok).toBe(true); + }); + + it('6. rejects a governed PR whose Unreleased section has no reference at all', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'fix(pwa): gate cache activation on precache success', + changelog: UNRELEASED('- **PWA precache admission:** hardens activation.'), + }); + expect(result).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it('7. rejects a governed PR whose Unreleased entry cites a DIFFERENT PR number', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'fix(pwa): gate cache activation on precache success', + changelog: UNRELEASED('- **Unrelated change:** see write-up. PR #501.'), + }); + expect(result).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it('8. rejects a bare "#" reference lacking the "PR" grammar', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'fix(pwa): gate cache activation on precache success', + changelog: UNRELEASED('- **PWA precache admission:** hardens activation. #700.'), + }); + expect(result).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it('9. rejects a trailing "(#)" squash-style reference pre-merge (no "PR" word)', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'fix(pwa): gate cache activation on precache success', + changelog: UNRELEASED('- **PWA precache admission:** hardens activation (#700).'), + }); + expect(result).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it('10. skips a non-governed PR title (e.g. "docs:") regardless of Unreleased content', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'docs: fix typo in README', + changelog: UNRELEASED('- unrelated content with no reference at all'), + }); + expect(result).toEqual({ ok: true, reason: 'not-governed' }); + }); + + it('11. does not let "PR #700" satisfy a check for the shorter number 70 (no partial-left match)', () => { + const result = checkPrChangelogReference({ + prNumber: 70, + prTitle: 'fix(core): narrow number-boundary regression', + changelog: UNRELEASED('- **Unrelated:** references a different change. PR #700.'), + }); + expect(result).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it('12. does not let "PR #7000" satisfy a check for the shorter number 700 (no partial-right match)', () => { + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'fix(core): narrow number-boundary regression', + changelog: UNRELEASED('- **Unrelated:** references a different change. PR #7000.'), + }); + expect(result).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it('13. tolerates case and missing whitespace, e.g. "pr#700"', () => { + expect(isReferencedByPrLabel(700, 'lowercase and tight: pr#700 done')).toBe(true); + const result = checkPrChangelogReference({ + prNumber: 700, + prTitle: 'fix(pwa): gate cache activation on precache success', + changelog: UNRELEASED('- **PWA precache admission:** hardens activation. pr#700.'), + }); + expect(result.ok).toBe(true); + }); + + describe('historical-incident fixtures (real CHANGELOG text from the three prior recoveries)', () => { + it('#678->#679: the original merged state (no reference) would have been rejected', () => { + const originalUnreleased = UNRELEASED( + "- **Tauri plugin version parity:** bumped npm packages\n (`@tauri-apps/plugin-http`, `@tauri-apps/plugin-notification`) after #661 bumped only the Rust\n side, failing every platform's Tauri release build. Bumped the npm packages to match; added\n `check-tauri-plugin-versions.mjs`, a cheap CI guard catching this class of mismatch before the\n next release tag instead of at tag-triggered release time.", + ); + const result = checkPrChangelogReference({ + prNumber: 678, + prTitle: 'fix(ci): sync Tauri plugin npm/Rust versions', + changelog: originalUnreleased, + }); + expect(result).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it('#678->#679: the real recovery entry text ("...PR #678.") satisfies the gate', () => { + const recoveredUnreleased = UNRELEASED( + "- **Tauri plugin version parity:** bumped npm packages\n (`@tauri-apps/plugin-http`, `@tauri-apps/plugin-notification`) after #661 bumped only the Rust\n side, failing every platform's Tauri release build. Bumped the npm packages to match; added\n `check-tauri-plugin-versions.mjs`, a cheap CI guard catching this class of mismatch before the\n next release tag instead of at tag-triggered release time. PR #678.", + ); + const result = checkPrChangelogReference({ + prNumber: 678, + prTitle: 'fix(ci): sync Tauri plugin npm/Rust versions', + changelog: recoveredUnreleased, + }); + expect(result).toEqual({ ok: true, reason: 'referenced' }); + }); + + it('#684->#685: the real recovery entry text ("...PR #684.") satisfies the gate', () => { + const recoveredUnreleased = UNRELEASED( + '- **Tauri parity-preflight release gate:** added a `parity-preflight` job (checkout + one\n dependency-free Node script, no `pnpm install`) that runs `check-tauri-plugin-versions.mjs`\n before the bundle matrix starts, gated behind `verify-release-tag` so no repository code runs\n on an unverified release tag. On both `workflow_dispatch` and tag pushes. PR #684.', + ); + const result = checkPrChangelogReference({ + prNumber: 684, + prTitle: 'fix(ci): add Tauri release parity-preflight gate', + changelog: recoveredUnreleased, + }); + expect(result).toEqual({ ok: true, reason: 'referenced' }); + }); + + it('#699->#700: the real recovery entry text ("...PR #699.") satisfies the gate', () => { + const recoveredUnreleased = UNRELEASED( + '- **PWA: a failed precache can no longer displace a working service-worker generation (#525):**\n `install` now rethrows on failure so the whole installation rejects. PR #699.', + ); + const result = checkPrChangelogReference({ + prNumber: 699, + prTitle: 'fix(pwa): gate service-worker cache-generation activation on precache success', + changelog: recoveredUnreleased, + }); + expect(result).toEqual({ ok: true, reason: 'referenced' }); + }); + }); +}); From 578782552377a813ce83724677f4c5a2f583c4f0 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:08:52 +0200 Subject: [PATCH 2/9] docs: reference PR #705 in the CHANGELOG PR-admission gate entry --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b55bb0180..f162e9449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 explicitly in `PRECACHE_URLS`; these are now resolved and deduplicated at runtime (against each other too, not just the explicit list) before reaching `cache.addAll()`, while the manifest keeps its content-hash revision tracking for update detection. PR #699. +- **A governed PR can no longer merge without a CHANGELOG reference to itself:** the completeness + gate in `check-doc-metrics.mjs` only enforced a PR-number reference in `[Unreleased]` after + squash-merge, once the commit already carried `(#N)` — nothing stopped a governed PR from merging + without ever adding the entry, even though its real PR number is knowable before merge. Recurred + three times (#678→#679, #684→#685, #699→#700). A new pre-merge admission gate + (`.github/workflows/pr-changelog-reference.yml` + `check-pr-changelog-reference.mjs`, run from the + PR's base ref to prevent self-weakening) now fails a governed PR's CI unless `[Unreleased]` already + references it as `PR #`. PR #705. ### Documentation From c1ab3a5c7fd65b74f4a52df404ac7c522ff3dc3d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:18:36 +0200 Subject: [PATCH 3/9] test: reduce duplication in checkPrChangelogReference regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeScene flagged the new test file's code health below 10.00 due to repeated per-test literal boilerplate. Factored a shared fixture builder and consolidated closely related cases into it.each() tables — same 18 assertions, same coverage, no behavior change to the checker itself. --- tests/unit/checkPrChangelogReference.test.ts | 242 ++++++++----------- 1 file changed, 101 insertions(+), 141 deletions(-) diff --git a/tests/unit/checkPrChangelogReference.test.ts b/tests/unit/checkPrChangelogReference.test.ts index 1b66578b9..e4305342f 100644 --- a/tests/unit/checkPrChangelogReference.test.ts +++ b/tests/unit/checkPrChangelogReference.test.ts @@ -12,172 +12,132 @@ import { const UNRELEASED = (body: string) => `## [Unreleased]\n\n### Fixed\n\n${body}\n\n## [1.28.6]\n`; -describe('checkPrChangelogReference', () => { - it('1. accepts a governed "feat:" PR whose Unreleased entry cites "PR #"', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'feat(writer): add outline templates', - changelog: UNRELEASED('- **Outline templates:** adds starter templates. PR #700.'), - }); - expect(result).toEqual({ ok: true, reason: 'referenced' }); - }); - - it('2. accepts a governed "fix:" PR whose Unreleased entry cites "PR #"', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'fix(pwa): gate cache activation on precache success', - changelog: UNRELEASED('- **PWA precache admission:** hardens activation. PR #700.'), - }); - expect(result.ok).toBe(true); +const GOVERNED_TITLE = 'fix(pwa): gate cache activation on precache success'; + +interface CheckInput { + prNumber: number; + prTitle: string; + entryBody: string; +} + +// QNBS-v3: single fixture builder for every case below — CodeScene flagged the prior per-test literal duplication as unhealthy new code. +function check({ prNumber = 700, prTitle = GOVERNED_TITLE, entryBody }: Partial) { + return checkPrChangelogReference({ + prNumber, + prTitle, + changelog: UNRELEASED(entryBody ?? '- **PWA precache admission:** hardens activation.'), }); +} - it('3. accepts a governed "perf:" PR whose Unreleased entry cites "PR #"', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'perf(rag): batch embedding lookups', - changelog: UNRELEASED('- **RAG batching:** reduces lookup overhead. PR #700.'), - }); - expect(result.ok).toBe(true); - }); - - it('4. accepts a scoped governed title, e.g. "fix(pwa): ..."', () => { - const result = checkPrChangelogReference({ - prNumber: 525, - prTitle: 'fix(pwa): gate cache-generation activation on precache success', - changelog: UNRELEASED('- **SW admission gate:** described here. PR #525.'), - }); - expect(result.ok).toBe(true); - }); - - it('5. accepts a breaking-change "!" governed title, e.g. "feat(api)!: ..."', () => { - const result = checkPrChangelogReference({ - prNumber: 812, - prTitle: 'feat(api)!: drop legacy v1 provider adapter', - changelog: UNRELEASED('- **Legacy provider removal:** drops v1 adapter. PR #812.'), - }); - expect(result.ok).toBe(true); - }); - - it('6. rejects a governed PR whose Unreleased section has no reference at all', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'fix(pwa): gate cache activation on precache success', - changelog: UNRELEASED('- **PWA precache admission:** hardens activation.'), - }); - expect(result).toEqual({ ok: false, reason: 'missing-reference' }); - }); - - it('7. rejects a governed PR whose Unreleased entry cites a DIFFERENT PR number', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'fix(pwa): gate cache activation on precache success', - changelog: UNRELEASED('- **Unrelated change:** see write-up. PR #501.'), - }); - expect(result).toEqual({ ok: false, reason: 'missing-reference' }); - }); - - it('8. rejects a bare "#" reference lacking the "PR" grammar', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'fix(pwa): gate cache activation on precache success', - changelog: UNRELEASED('- **PWA precache admission:** hardens activation. #700.'), - }); - expect(result).toEqual({ ok: false, reason: 'missing-reference' }); - }); - - it('9. rejects a trailing "(#)" squash-style reference pre-merge (no "PR" word)', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'fix(pwa): gate cache activation on precache success', - changelog: UNRELEASED('- **PWA precache admission:** hardens activation (#700).'), - }); - expect(result).toEqual({ ok: false, reason: 'missing-reference' }); +describe('checkPrChangelogReference', () => { + it.each([ + [ + '1. "feat:" title', + 'feat(writer): add outline templates', + 700, + '- Adds starter templates. PR #700.', + ], + [ + '2. "fix:" title', + 'fix(pwa): gate cache activation on precache success', + 700, + '- Hardens activation. PR #700.', + ], + [ + '3. "perf:" title', + 'perf(rag): batch embedding lookups', + 700, + '- Reduces lookup overhead. PR #700.', + ], + [ + '4. scoped title', + 'fix(pwa): gate cache-generation activation', + 525, + '- Described here. PR #525.', + ], + [ + '5. breaking-change "!" title', + 'feat(api)!: drop legacy v1 provider adapter', + 812, + '- Drops v1 adapter. PR #812.', + ], + ['13. case/whitespace tolerant "pr#N"', GOVERNED_TITLE, 700, '- Hardens activation. pr#700.'], + ] as const)( + 'accepts a governed PR whose entry cites its own number (%s)', + (_label, prTitle, prNumber, entryBody) => { + expect(check({ prNumber, prTitle, entryBody }).ok).toBe(true); + }, + ); + + it.each([ + ['6. no reference at all', '- Hardens activation.'], + ['7. a DIFFERENT PR number', '- Unrelated change, see write-up. PR #501.'], + ['8. bare "#N" lacking the "PR" grammar', '- Hardens activation. #700.'], + ['9. trailing "(#N)" squash style pre-merge', '- Hardens activation (#700).'], + ] as const)('rejects a governed PR whose entry has %s', (_label, entryBody) => { + expect(check({ entryBody })).toEqual({ ok: false, reason: 'missing-reference' }); }); - it('10. skips a non-governed PR title (e.g. "docs:") regardless of Unreleased content', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'docs: fix typo in README', - changelog: UNRELEASED('- unrelated content with no reference at all'), + it('10. skips a non-governed PR title regardless of Unreleased content', () => { + expect( + check({ prTitle: 'docs: fix typo in README', entryBody: '- no reference at all' }), + ).toEqual({ + ok: true, + reason: 'not-governed', }); - expect(result).toEqual({ ok: true, reason: 'not-governed' }); }); it('11. does not let "PR #700" satisfy a check for the shorter number 70 (no partial-left match)', () => { - const result = checkPrChangelogReference({ - prNumber: 70, - prTitle: 'fix(core): narrow number-boundary regression', - changelog: UNRELEASED('- **Unrelated:** references a different change. PR #700.'), + expect(check({ prNumber: 70, entryBody: '- Unrelated. PR #700.' })).toEqual({ + ok: false, + reason: 'missing-reference', }); - expect(result).toEqual({ ok: false, reason: 'missing-reference' }); }); it('12. does not let "PR #7000" satisfy a check for the shorter number 700 (no partial-right match)', () => { - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'fix(core): narrow number-boundary regression', - changelog: UNRELEASED('- **Unrelated:** references a different change. PR #7000.'), + expect(check({ entryBody: '- Unrelated. PR #7000.' })).toEqual({ + ok: false, + reason: 'missing-reference', }); - expect(result).toEqual({ ok: false, reason: 'missing-reference' }); }); - it('13. tolerates case and missing whitespace, e.g. "pr#700"', () => { + it('isReferencedByPrLabel directly tolerates missing whitespace, e.g. "pr#700"', () => { expect(isReferencedByPrLabel(700, 'lowercase and tight: pr#700 done')).toBe(true); - const result = checkPrChangelogReference({ - prNumber: 700, - prTitle: 'fix(pwa): gate cache activation on precache success', - changelog: UNRELEASED('- **PWA precache admission:** hardens activation. pr#700.'), - }); - expect(result.ok).toBe(true); }); describe('historical-incident fixtures (real CHANGELOG text from the three prior recoveries)', () => { + // QNBS-v3: real bullet text from each incident's own recovery commit, factored so only the trailing reference varies. + const tauriPluginParityBody = + "- **Tauri plugin version parity:** bumped npm packages\n (`@tauri-apps/plugin-http`, `@tauri-apps/plugin-notification`) after #661 bumped only the Rust\n side, failing every platform's Tauri release build. Bumped the npm packages to match; added\n `check-tauri-plugin-versions.mjs`, a cheap CI guard catching this class of mismatch before the\n next release tag instead of at tag-triggered release time."; + const parityPreflightBody = + '- **Tauri parity-preflight release gate:** added a `parity-preflight` job (checkout + one\n dependency-free Node script, no `pnpm install`) that runs `check-tauri-plugin-versions.mjs`\n before the bundle matrix starts, gated behind `verify-release-tag` so no repository code runs\n on an unverified release tag. On both `workflow_dispatch` and tag pushes.'; + const precacheAdmissionBody = + '- **PWA: a failed precache can no longer displace a working service-worker generation (#525):**\n `install` now rethrows on failure so the whole installation rejects.'; + it('#678->#679: the original merged state (no reference) would have been rejected', () => { - const originalUnreleased = UNRELEASED( - "- **Tauri plugin version parity:** bumped npm packages\n (`@tauri-apps/plugin-http`, `@tauri-apps/plugin-notification`) after #661 bumped only the Rust\n side, failing every platform's Tauri release build. Bumped the npm packages to match; added\n `check-tauri-plugin-versions.mjs`, a cheap CI guard catching this class of mismatch before the\n next release tag instead of at tag-triggered release time.", - ); - const result = checkPrChangelogReference({ + const result = check({ prNumber: 678, prTitle: 'fix(ci): sync Tauri plugin npm/Rust versions', - changelog: originalUnreleased, + entryBody: tauriPluginParityBody, }); expect(result).toEqual({ ok: false, reason: 'missing-reference' }); }); - it('#678->#679: the real recovery entry text ("...PR #678.") satisfies the gate', () => { - const recoveredUnreleased = UNRELEASED( - "- **Tauri plugin version parity:** bumped npm packages\n (`@tauri-apps/plugin-http`, `@tauri-apps/plugin-notification`) after #661 bumped only the Rust\n side, failing every platform's Tauri release build. Bumped the npm packages to match; added\n `check-tauri-plugin-versions.mjs`, a cheap CI guard catching this class of mismatch before the\n next release tag instead of at tag-triggered release time. PR #678.", - ); - const result = checkPrChangelogReference({ - prNumber: 678, - prTitle: 'fix(ci): sync Tauri plugin npm/Rust versions', - changelog: recoveredUnreleased, - }); - expect(result).toEqual({ ok: true, reason: 'referenced' }); - }); - - it('#684->#685: the real recovery entry text ("...PR #684.") satisfies the gate', () => { - const recoveredUnreleased = UNRELEASED( - '- **Tauri parity-preflight release gate:** added a `parity-preflight` job (checkout + one\n dependency-free Node script, no `pnpm install`) that runs `check-tauri-plugin-versions.mjs`\n before the bundle matrix starts, gated behind `verify-release-tag` so no repository code runs\n on an unverified release tag. On both `workflow_dispatch` and tag pushes. PR #684.', - ); - const result = checkPrChangelogReference({ - prNumber: 684, - prTitle: 'fix(ci): add Tauri release parity-preflight gate', - changelog: recoveredUnreleased, - }); - expect(result).toEqual({ ok: true, reason: 'referenced' }); - }); - - it('#699->#700: the real recovery entry text ("...PR #699.") satisfies the gate', () => { - const recoveredUnreleased = UNRELEASED( - '- **PWA: a failed precache can no longer displace a working service-worker generation (#525):**\n `install` now rethrows on failure so the whole installation rejects. PR #699.', - ); - const result = checkPrChangelogReference({ - prNumber: 699, - prTitle: 'fix(pwa): gate service-worker cache-generation activation on precache success', - changelog: recoveredUnreleased, - }); - expect(result).toEqual({ ok: true, reason: 'referenced' }); - }); + it.each([ + ['#678->#679', 678, 'fix(ci): sync Tauri plugin npm/Rust versions', tauriPluginParityBody], + ['#684->#685', 684, 'fix(ci): add Tauri release parity-preflight gate', parityPreflightBody], + [ + '#699->#700', + 699, + 'fix(pwa): gate service-worker cache-generation activation on precache success', + precacheAdmissionBody, + ], + ] as const)( + '%s: the real recovery entry text satisfies the gate', + (_label, prNumber, prTitle, body) => { + const result = check({ prNumber, prTitle, entryBody: `${body} PR #${prNumber}.` }); + expect(result).toEqual({ ok: true, reason: 'referenced' }); + }, + ); }); }); From dec4127adb9ff619a93b7bf44185a8f6f345ecc4 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:21:08 +0200 Subject: [PATCH 4/9] docs: sync README test-count metrics after test-file refactor --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4402712b3..81ceda289 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7668+ tests / 605 files + 7656+ tests / 605 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7668+ tests / 605 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7656+ tests / 605 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` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7668+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7656+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**. **Current test metrics (2026-09-10, source-synchronized; CI remains authoritative for pass/fail):** -- **7668+ unit tests** across **605 test files** — CI is authoritative for pass/fail +- **7656+ unit tests** across **605 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) From 3ce40e9f43fc9d8a1e51ab21bc14d62116006331 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:31:12 +0200 Subject: [PATCH 5/9] fix(ci): scope CHANGELOG PR-reference check to actual bullet entries The check previously tested the whole raw [Unreleased] section text, so a PR number mentioned only in prose (e.g. a reviewer note directly under a ### heading, not inside a real release-note bullet) could satisfy admission without ever adding a genuine changelog entry. Scoped to parsed bullet entries (joining soft-wrapped continuation lines, mirroring check-doc-metrics.mjs's splitUnreleasedEntries) so only a reference inside an actual bullet counts. Mutation-tested: reverted to whole-section matching, confirmed exactly the new prose-bypass regression test failed, restored. --- README.md | 8 ++--- scripts/check-pr-changelog-reference.d.mts | 2 +- scripts/check-pr-changelog-reference.mjs | 31 ++++++++++++++++++-- tests/unit/checkPrChangelogReference.test.ts | 14 +++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 81ceda289..f5dae0be2 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7656+ tests / 605 files + 7658+ tests / 605 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7656+ tests / 605 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7658+ tests / 605 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` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7656+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7658+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**. **Current test metrics (2026-09-10, source-synchronized; CI remains authoritative for pass/fail):** -- **7656+ unit tests** across **605 test files** — CI is authoritative for pass/fail +- **7658+ unit tests** across **605 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/scripts/check-pr-changelog-reference.d.mts b/scripts/check-pr-changelog-reference.d.mts index 3f81a0070..b5f7bc2d2 100644 --- a/scripts/check-pr-changelog-reference.d.mts +++ b/scripts/check-pr-changelog-reference.d.mts @@ -1,4 +1,4 @@ -export function isReferencedByPrLabel(prNumber: number, unreleasedSection: string): boolean; +export function isReferencedByPrLabel(prNumber: number, text: string): boolean; export interface CheckPrChangelogReferenceInput { prNumber: number; diff --git a/scripts/check-pr-changelog-reference.mjs b/scripts/check-pr-changelog-reference.mjs index b96c227e7..10cbf5c01 100644 --- a/scripts/check-pr-changelog-reference.mjs +++ b/scripts/check-pr-changelog-reference.mjs @@ -29,9 +29,32 @@ function getUnreleasedSectionText(changelog) { return section.replace(/|$)/g, ''); } +// QNBS-v3: duplicated from check-doc-metrics.mjs's splitUnreleasedEntries for the same self-containment reason — joins a bullet's own soft-wrapped continuation lines into one entry. +function extractBulletEntries(unreleasedSection) { + const entries = []; + let current = []; + const flush = () => { + if (current.length > 0) entries.push(current.join(' ')); + current = []; + }; + for (const rawLine of unreleasedSection.split('\n')) { + const line = rawLine.trim(); + if (/^-\s/.test(line)) { + flush(); + current.push(line); + } else if (current.length > 0 && line !== '') { + current.push(line); + } else if (line === '') { + flush(); + } + } + flush(); + return entries; +} + // QNBS-v3: exact "PR #NNN" grammar, stricter than check-doc-metrics.mjs's post-merge bare "#NNN" matcher — pre-merge there is no squash-appended "(#NNN)" to anchor on, so a bare "#NNN" could belong to an unrelated issue/PR mention instead of a genuine self-reference. -export function isReferencedByPrLabel(prNumber, unreleasedSection) { - return new RegExp(`\\bPR\\s*#${prNumber}(?!\\d)`, 'i').test(unreleasedSection); +export function isReferencedByPrLabel(prNumber, text) { + return new RegExp(`\\bPR\\s*#${prNumber}(?!\\d)`, 'i').test(text); } /** Pure decision function — kept separate from I/O so it is directly unit-testable. */ @@ -39,8 +62,10 @@ export function checkPrChangelogReference({ prNumber, prTitle, changelog }) { if (!GOVERNED_COMMIT_TYPE.test(prTitle ?? '')) { return { ok: true, reason: 'not-governed' }; } + // QNBS-v3: scoped to actual bullet entries, not the whole section — a PR number floating in prose or a sub-heading (not inside a real release-note bullet) must not count as documentation. const unreleasedSection = getUnreleasedSectionText(changelog ?? ''); - return isReferencedByPrLabel(prNumber, unreleasedSection) + const bulletEntries = extractBulletEntries(unreleasedSection); + return bulletEntries.some((entry) => isReferencedByPrLabel(prNumber, entry)) ? { ok: true, reason: 'referenced' } : { ok: false, reason: 'missing-reference' }; } diff --git a/tests/unit/checkPrChangelogReference.test.ts b/tests/unit/checkPrChangelogReference.test.ts index e4305342f..90261c5b5 100644 --- a/tests/unit/checkPrChangelogReference.test.ts +++ b/tests/unit/checkPrChangelogReference.test.ts @@ -105,6 +105,20 @@ describe('checkPrChangelogReference', () => { expect(isReferencedByPrLabel(700, 'lowercase and tight: pr#700 done')).toBe(true); }); + it('14. rejects a reference that appears only in prose outside any bullet entry', () => { + const changelog = `## [Unreleased]\n\n### Fixed\n\nNote: tracked under PR #700, changelog entry pending.\n\n- **Something else:** unrelated bullet content.\n\n## [1.28.6]\n`; + expect( + checkPrChangelogReference({ prNumber: 700, prTitle: GOVERNED_TITLE, changelog }), + ).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it("15. accepts a reference on a bullet's own soft-wrapped continuation line", () => { + const changelog = `## [Unreleased]\n\n### Fixed\n\n- **Hardens activation:** wraps across\n a continuation line. PR #700.\n\n## [1.28.6]\n`; + expect( + checkPrChangelogReference({ prNumber: 700, prTitle: GOVERNED_TITLE, changelog }), + ).toEqual({ ok: true, reason: 'referenced' }); + }); + describe('historical-incident fixtures (real CHANGELOG text from the three prior recoveries)', () => { // QNBS-v3: real bullet text from each incident's own recovery commit, factored so only the trailing reference varies. const tauriPluginParityBody = From b052825115439207289e160c3442250ffa12ce5d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:03:35 +0200 Subject: [PATCH 6/9] fix(ci): close two review-found bypasses in the CHANGELOG PR-reference gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isReferencedByPrLabel used (?!\d) as its trailing boundary, so a malformed near-miss like "PR #705alpha" or "PR #705_internal" satisfied the gate. Widened to (?!\w), a full word boundary, matching the existing post-merge checker's own boundary discipline. - extractBulletEntries appended any non-blank line to the current bullet as a soft-wrap continuation, including a Markdown heading with no blank line before it — so a heading like "### Notes: PR #700" right after an unrelated bullet could satisfy the gate. Now flushes the current entry on a heading line before the continuation check. Also fails closed (instead of silently skipping) when a pull_request event payload is missing its numeric "number" field, rather than treating that the same as a genuinely absent pull_request event. 5 new regression tests (word-boundary near-misses x2, heading-continuation bypass, doubling as the mutation-tested proof for both fixes). --- README.md | 8 ++++---- scripts/check-pr-changelog-reference.mjs | 14 +++++++++++--- tests/unit/checkPrChangelogReference.test.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f5dae0be2..d991eb770 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7658+ tests / 605 files + 7659+ tests / 605 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7658+ tests / 605 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7659+ tests / 605 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` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7658+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7659+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**. **Current test metrics (2026-09-10, source-synchronized; CI remains authoritative for pass/fail):** -- **7658+ unit tests** across **605 test files** — CI is authoritative for pass/fail +- **7659+ unit tests** across **605 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/scripts/check-pr-changelog-reference.mjs b/scripts/check-pr-changelog-reference.mjs index 10cbf5c01..1cefb2bbc 100644 --- a/scripts/check-pr-changelog-reference.mjs +++ b/scripts/check-pr-changelog-reference.mjs @@ -42,6 +42,8 @@ function extractBulletEntries(unreleasedSection) { if (/^-\s/.test(line)) { flush(); current.push(line); + } else if (/^#{1,6}\s/.test(line)) { + flush(); } else if (current.length > 0 && line !== '') { current.push(line); } else if (line === '') { @@ -52,9 +54,9 @@ function extractBulletEntries(unreleasedSection) { return entries; } -// QNBS-v3: exact "PR #NNN" grammar, stricter than check-doc-metrics.mjs's post-merge bare "#NNN" matcher — pre-merge there is no squash-appended "(#NNN)" to anchor on, so a bare "#NNN" could belong to an unrelated issue/PR mention instead of a genuine self-reference. +// QNBS-v3: exact "PR #NNN" grammar with a full trailing word boundary (rejects "PR #705alpha"/"PR #705_"), stricter than check-doc-metrics.mjs's post-merge bare "#NNN" matcher since pre-merge there is no squash-appended "(#NNN)" to anchor on. export function isReferencedByPrLabel(prNumber, text) { - return new RegExp(`\\bPR\\s*#${prNumber}(?!\\d)`, 'i').test(text); + return new RegExp(`\\bPR\\s*#${prNumber}(?!\\w)`, 'i').test(text); } /** Pure decision function — kept separate from I/O so it is directly unit-testable. */ @@ -88,10 +90,16 @@ function main() { } const pr = payload.pull_request; - if (!pr || typeof pr.number !== 'number') { + if (!pr) { console.log('[check-pr-changelog-reference] not a pull_request event — skipping'); process.exit(0); } + if (typeof pr.number !== 'number') { + console.error( + '[check-pr-changelog-reference] pull_request event payload is missing a numeric "number" field', + ); + process.exit(1); + } let changelog; try { diff --git a/tests/unit/checkPrChangelogReference.test.ts b/tests/unit/checkPrChangelogReference.test.ts index 90261c5b5..72ad3bf28 100644 --- a/tests/unit/checkPrChangelogReference.test.ts +++ b/tests/unit/checkPrChangelogReference.test.ts @@ -119,6 +119,23 @@ describe('checkPrChangelogReference', () => { ).toEqual({ ok: true, reason: 'referenced' }); }); + it.each([ + ['16. a trailing letter suffix, e.g. "PR #700alpha"', '- Hardens activation. PR #700alpha.'], + [ + '17. a trailing underscore suffix, e.g. "PR #700_internal"', + '- Hardens activation. PR #700_internal.', + ], + ] as const)('rejects a near-miss reference with %s', (_label, entryBody) => { + expect(check({ entryBody })).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it('18. does not let a heading immediately after a bullet (no blank line) absorb the heading text as a continuation', () => { + const changelog = `## [Unreleased]\n\n### Fixed\n\n- **Something else:** unrelated bullet content.\n### Notes: tracked under PR #700\n\n## [1.28.6]\n`; + expect( + checkPrChangelogReference({ prNumber: 700, prTitle: GOVERNED_TITLE, changelog }), + ).toEqual({ ok: false, reason: 'missing-reference' }); + }); + describe('historical-incident fixtures (real CHANGELOG text from the three prior recoveries)', () => { // QNBS-v3: real bullet text from each incident's own recovery commit, factored so only the trailing reference varies. const tauriPluginParityBody = From e0a4ef2e9f8ae8e82d4eec40bd0188075e7654e1 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:28:38 +0200 Subject: [PATCH 7/9] fix(ci): strip comments before locating the [Unreleased] heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getUnreleasedSectionText searched for the heading in the raw changelog, then stripped HTML comments from the extracted section afterward. A commented-out template containing a literal "## [Unreleased]" line earlier in the file could hijack the section-boundary search — slicing off the opening "|$)/g, ''); + const heading = /^## \[Unreleased\]\s*$/m.exec(withoutComments); if (!heading) return ''; - const afterHeading = changelog.slice(heading.index + heading[0].length); + const afterHeading = withoutComments.slice(heading.index + heading[0].length); const nextHeading = afterHeading.search(/^##\s/m); - const section = nextHeading === -1 ? afterHeading : afterHeading.slice(0, nextHeading); - return section.replace(/|$)/g, ''); + return nextHeading === -1 ? afterHeading : afterHeading.slice(0, nextHeading); } // QNBS-v3: duplicated from check-doc-metrics.mjs's splitUnreleasedEntries for the same self-containment reason — joins a bullet's own soft-wrapped continuation lines into one entry. diff --git a/tests/unit/checkPrChangelogReference.test.ts b/tests/unit/checkPrChangelogReference.test.ts index 72ad3bf28..32615b4f5 100644 --- a/tests/unit/checkPrChangelogReference.test.ts +++ b/tests/unit/checkPrChangelogReference.test.ts @@ -136,6 +136,13 @@ describe('checkPrChangelogReference', () => { ).toEqual({ ok: false, reason: 'missing-reference' }); }); + it('19. does not let a commented-out fake "## [Unreleased]" heading earlier in the file hijack the section boundary', () => { + const changelog = `\n\n## [Unreleased]\n\n### Fixed\n\n- **Real fix:** unrelated content with no reference.\n\n## [1.28.6]\n`; + expect( + checkPrChangelogReference({ prNumber: 700, prTitle: GOVERNED_TITLE, changelog }), + ).toEqual({ ok: false, reason: 'missing-reference' }); + }); + describe('historical-incident fixtures (real CHANGELOG text from the three prior recoveries)', () => { // QNBS-v3: real bullet text from each incident's own recovery commit, factored so only the trailing reference varies. const tauriPluginParityBody = From 7cc65d9bbad96f75c20372df0366a9bc3f47455e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:05:54 +0200 Subject: [PATCH 8/9] fix(ci): reject malformed PR metadata and generalize bullet-continuation scoping - isValidPrMetadata (extracted for testability) now rejects a non-integer, zero, or negative PR number, and a missing/blank title, instead of only checking typeof number === 'number' (which admits NaN and negative values). Fails closed instead of silently exit-0'ing on a malformed event payload. - extractBulletEntries's heading-only flush was one instance of a broader bug class: any flush-left non-bullet line (blockquote, code fence, hr) was still absorbed as a continuation. Replaced with the general rule this project's own CHANGELOG entries already follow: a continuation line must be indented. A flush-left line that isn't a new bullet ends the current entry, without enumerating every Markdown block type individually. New regression tests for both, plus a blockquote-continuation case mirroring the heading one. Mutation-tested: each fix reverted individually, confirmed exactly its own tests fail, restored. --- README.md | 8 ++--- scripts/check-pr-changelog-reference.d.mts | 2 ++ scripts/check-pr-changelog-reference.mjs | 31 +++++++++++++------- tests/unit/checkPrChangelogReference.test.ts | 23 +++++++++++++++ 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 67eb9930b..4fb613786 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7660+ tests / 605 files + 7662+ tests / 605 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7660+ tests / 605 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7662+ tests / 605 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` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7660+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7662+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**. **Current test metrics (2026-09-10, source-synchronized; CI remains authoritative for pass/fail):** -- **7660+ unit tests** across **605 test files** — CI is authoritative for pass/fail +- **7662+ unit tests** across **605 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/scripts/check-pr-changelog-reference.d.mts b/scripts/check-pr-changelog-reference.d.mts index b5f7bc2d2..8e9c2690f 100644 --- a/scripts/check-pr-changelog-reference.d.mts +++ b/scripts/check-pr-changelog-reference.d.mts @@ -1,5 +1,7 @@ export function isReferencedByPrLabel(prNumber: number, text: string): boolean; +export function isValidPrMetadata(pr: unknown): boolean; + export interface CheckPrChangelogReferenceInput { prNumber: number; prTitle: string | undefined | null; diff --git a/scripts/check-pr-changelog-reference.mjs b/scripts/check-pr-changelog-reference.mjs index 787ded375..bd2b65ee2 100644 --- a/scripts/check-pr-changelog-reference.mjs +++ b/scripts/check-pr-changelog-reference.mjs @@ -29,7 +29,7 @@ function getUnreleasedSectionText(changelog) { return nextHeading === -1 ? afterHeading : afterHeading.slice(0, nextHeading); } -// QNBS-v3: duplicated from check-doc-metrics.mjs's splitUnreleasedEntries for the same self-containment reason — joins a bullet's own soft-wrapped continuation lines into one entry. +// QNBS-v3: a continuation line must be INDENTED (this project's own convention for a soft-wrapped bullet, confirmed in every real multi-line CHANGELOG entry) — any flush-left line that isn't itself a new bullet (heading, blockquote, code fence, hr, stray prose) ends the current entry instead of being absorbed, without needing to enumerate every Markdown block type individually. function extractBulletEntries(unreleasedSection) { const entries = []; let current = []; @@ -38,15 +38,13 @@ function extractBulletEntries(unreleasedSection) { current = []; }; for (const rawLine of unreleasedSection.split('\n')) { - const line = rawLine.trim(); - if (/^-\s/.test(line)) { + const trimmed = rawLine.trim(); + if (/^-\s/.test(trimmed)) { flush(); - current.push(line); - } else if (/^#{1,6}\s/.test(line)) { - flush(); - } else if (current.length > 0 && line !== '') { - current.push(line); - } else if (line === '') { + current.push(trimmed); + } else if (current.length > 0 && trimmed !== '' && /^\s/.test(rawLine)) { + current.push(trimmed); + } else { flush(); } } @@ -59,6 +57,17 @@ export function isReferencedByPrLabel(prNumber, text) { return new RegExp(`\\bPR\\s*#${prNumber}(?!\\w)`, 'i').test(text); } +// QNBS-v3: split out so main()'s CLI wiring stays a thin I/O shell — kept directly unit-testable against malformed event-payload shapes. +export function isValidPrMetadata(pr) { + return ( + Boolean(pr) && + Number.isSafeInteger(pr.number) && + pr.number > 0 && + typeof pr.title === 'string' && + pr.title.trim() !== '' + ); +} + /** Pure decision function — kept separate from I/O so it is directly unit-testable. */ export function checkPrChangelogReference({ prNumber, prTitle, changelog }) { if (!GOVERNED_COMMIT_TYPE.test(prTitle ?? '')) { @@ -94,9 +103,9 @@ function main() { console.log('[check-pr-changelog-reference] not a pull_request event — skipping'); process.exit(0); } - if (typeof pr.number !== 'number') { + if (!isValidPrMetadata(pr)) { console.error( - '[check-pr-changelog-reference] pull_request event payload is missing a numeric "number" field', + '[check-pr-changelog-reference] pull_request event payload has invalid PR metadata', ); process.exit(1); } diff --git a/tests/unit/checkPrChangelogReference.test.ts b/tests/unit/checkPrChangelogReference.test.ts index 32615b4f5..12d5c225d 100644 --- a/tests/unit/checkPrChangelogReference.test.ts +++ b/tests/unit/checkPrChangelogReference.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'; import { checkPrChangelogReference, isReferencedByPrLabel, + isValidPrMetadata, } from '../../scripts/check-pr-changelog-reference.mjs'; const UNRELEASED = (body: string) => `## [Unreleased]\n\n### Fixed\n\n${body}\n\n## [1.28.6]\n`; @@ -143,6 +144,28 @@ describe('checkPrChangelogReference', () => { ).toEqual({ ok: false, reason: 'missing-reference' }); }); + it('20. does not let a blockquote immediately after a bullet (no blank line) absorb its text as a continuation', () => { + const changelog = `## [Unreleased]\n\n### Fixed\n\n- **Something else:** unrelated bullet content.\n> Tracking only: PR #700\n\n## [1.28.6]\n`; + expect( + checkPrChangelogReference({ prNumber: 700, prTitle: GOVERNED_TITLE, changelog }), + ).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + it.each([ + ['missing title', { number: 705 }], + ['blank title', { number: 705, title: ' ' }], + ['non-integer number', { number: 705.5, title: GOVERNED_TITLE }], + ['zero number', { number: 0, title: GOVERNED_TITLE }], + ['negative number', { number: -1, title: GOVERNED_TITLE }], + ['null payload', null], + ] as const)('isValidPrMetadata rejects %s', (_label, pr) => { + expect(isValidPrMetadata(pr)).toBe(false); + }); + + it('isValidPrMetadata accepts well-formed PR metadata', () => { + expect(isValidPrMetadata({ number: 705, title: GOVERNED_TITLE })).toBe(true); + }); + describe('historical-incident fixtures (real CHANGELOG text from the three prior recoveries)', () => { // QNBS-v3: real bullet text from each incident's own recovery commit, factored so only the trailing reference varies. const tauriPluginParityBody = From b02958dddf6c51e3a5a4de745666e95bff31824f Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:17:41 +0200 Subject: [PATCH 9/9] refactor(ci): extract isIndentedContinuation to simplify extractBulletEntries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeScene flagged extractBulletEntries' compound boolean condition as too complex. Named predicate, no behavior change — all 32 existing tests pass unmodified. --- scripts/check-pr-changelog-reference.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/check-pr-changelog-reference.mjs b/scripts/check-pr-changelog-reference.mjs index bd2b65ee2..ed77c1a30 100644 --- a/scripts/check-pr-changelog-reference.mjs +++ b/scripts/check-pr-changelog-reference.mjs @@ -29,6 +29,11 @@ function getUnreleasedSectionText(changelog) { return nextHeading === -1 ? afterHeading : afterHeading.slice(0, nextHeading); } +// QNBS-v3: named predicate so extractBulletEntries reads as a flat 3-way dispatch instead of one compound boolean condition. +function isIndentedContinuation(rawLine, trimmedLine, hasOpenEntry) { + return hasOpenEntry && trimmedLine !== '' && /^\s/.test(rawLine); +} + // QNBS-v3: a continuation line must be INDENTED (this project's own convention for a soft-wrapped bullet, confirmed in every real multi-line CHANGELOG entry) — any flush-left line that isn't itself a new bullet (heading, blockquote, code fence, hr, stray prose) ends the current entry instead of being absorbed, without needing to enumerate every Markdown block type individually. function extractBulletEntries(unreleasedSection) { const entries = []; @@ -42,7 +47,7 @@ function extractBulletEntries(unreleasedSection) { if (/^-\s/.test(trimmed)) { flush(); current.push(trimmed); - } else if (current.length > 0 && trimmed !== '' && /^\s/.test(rawLine)) { + } else if (isIndentedContinuation(rawLine, trimmed, current.length > 0)) { current.push(trimmed); } else { flush();