Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/pr-changelog-reference.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: PR CHANGELOG Reference Guard
Comment thread
qnbs marked this conversation as resolved.
on:
pull_request:
Comment thread
qnbs marked this conversation as resolved.
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
Comment thread
qnbs marked this conversation as resolved.
else
echo "::notice::check-pr-changelog-reference.mjs not found on base ref (bootstrap PR) — using this PR's own copy this one time."
Comment thread
qnbs marked this conversation as resolved.
fi
node "$CHECKER"
Comment thread
qnbs marked this conversation as resolved.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 #<N>`. PR #705.

### Documentation

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2942_keys-0EA5E9" alt="i18n 19 locales — 2942 keys">
<img src="https://img.shields.io/badge/Tests-7651%2B_%2F_604_files-22C55E" alt="7651+ tests / 604 files">
<img src="https://img.shields.io/badge/Tests-7660%2B_%2F_605_files-22C55E" alt="7660+ tests / 605 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -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 (7660+ 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` |
Expand Down Expand Up @@ -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 (7660+ tests, 605 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -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
- **7660+ 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)

Expand Down
18 changes: 18 additions & 0 deletions scripts/check-pr-changelog-reference.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function isReferencedByPrLabel(prNumber: number, text: 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;
133 changes: 133 additions & 0 deletions scripts/check-pr-changelog-reference.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/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 #<N>". 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 "<!--" before comment-removal runs left the fake section's own content unstrippable.
function getUnreleasedSectionText(changelog) {
const withoutComments = changelog.replace(/<!--[\s\S]*?(?:-->|$)/g, '');
Comment thread
qnbs marked this conversation as resolved.
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: 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 (/^#{1,6}\s/.test(line)) {
flush();
} else if (current.length > 0 && line !== '') {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
current.push(line);
Comment thread
qnbs marked this conversation as resolved.
Outdated
Comment thread
qnbs marked this conversation as resolved.
Outdated
} else if (line === '') {
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);
}

/** 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' }
Comment thread
qnbs marked this conversation as resolved.
: { 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 (typeof pr.number !== 'number') {
console.error(
'[check-pr-changelog-reference] pull_request event payload is missing a numeric "number" field',
);
process.exit(1);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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)) {
Comment thread
qnbs marked this conversation as resolved.
main();
}
Loading
Loading