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/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 diff --git a/README.md b/README.md index 690e90e89..4fb613786 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 + 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 (7651+ tests / 604 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 (7651+ tests, 604 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):** -- **7651+ unit tests** across **604 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 new file mode 100644 index 000000000..8e9c2690f --- /dev/null +++ b/scripts/check-pr-changelog-reference.d.mts @@ -0,0 +1,20 @@ +export function isReferencedByPrLabel(prNumber: number, text: string): boolean; + +export function isValidPrMetadata(pr: unknown): 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..ed77c1a30 --- /dev/null +++ b/scripts/check-pr-changelog-reference.mjs @@ -0,0 +1,147 @@ +#!/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: strips comments from the WHOLE document before searching for the heading — a commented-out template containing a literal "## [Unreleased]" line earlier in the file would otherwise hijack the section boundary, since slicing off the opening "|$)/g, ''); + const heading = /^## \[Unreleased\]\s*$/m.exec(withoutComments); + if (!heading) return ''; + const afterHeading = withoutComments.slice(heading.index + heading[0].length); + const nextHeading = afterHeading.search(/^##\s/m); + 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 = []; + let current = []; + const flush = () => { + if (current.length > 0) entries.push(current.join(' ')); + current = []; + }; + for (const rawLine of unreleasedSection.split('\n')) { + const trimmed = rawLine.trim(); + if (/^-\s/.test(trimmed)) { + flush(); + current.push(trimmed); + } else if (isIndentedContinuation(rawLine, trimmed, current.length > 0)) { + current.push(trimmed); + } else { + flush(); + } + } + flush(); + return entries; +} + +// 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}(?!\\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 ?? '')) { + 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 ?? ''); + const bulletEntries = extractBulletEntries(unreleasedSection); + return bulletEntries.some((entry) => isReferencedByPrLabel(prNumber, entry)) + ? { 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) { + console.log('[check-pr-changelog-reference] not a pull_request event — skipping'); + process.exit(0); + } + if (!isValidPrMetadata(pr)) { + console.error( + '[check-pr-changelog-reference] pull_request event payload has invalid PR metadata', + ); + process.exit(1); + } + + 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..12d5c225d --- /dev/null +++ b/tests/unit/checkPrChangelogReference.test.ts @@ -0,0 +1,204 @@ +// @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, + 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`; + +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.'), + }); +} + +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 regardless of Unreleased content', () => { + expect( + check({ prTitle: 'docs: fix typo in README', entryBody: '- no reference at all' }), + ).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)', () => { + expect(check({ prNumber: 70, entryBody: '- Unrelated. PR #700.' })).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)', () => { + expect(check({ entryBody: '- Unrelated. PR #7000.' })).toEqual({ + ok: false, + reason: 'missing-reference', + }); + }); + + it('isReferencedByPrLabel directly tolerates missing whitespace, e.g. "pr#700"', () => { + 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' }); + }); + + 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' }); + }); + + 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' }); + }); + + 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 = + "- **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 result = check({ + prNumber: 678, + prTitle: 'fix(ci): sync Tauri plugin npm/Rust versions', + entryBody: tauriPluginParityBody, + }); + expect(result).toEqual({ ok: false, reason: 'missing-reference' }); + }); + + 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' }); + }, + ); + }); +});