Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .github/CI-AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ This document summarizes the **current inventory** and **stabilization measures*
|-------|--------|-------|
| Node (recommended) | [`.nvmrc`](../.nvmrc) | **22** |
| Node (CI matrix) | `ci.yml` `quality` | **22** and **24** (explicit, no `node` = "current") |
| pnpm | `package.json` `packageManager` | **11.5.2** |
| pnpm | `package.json` `packageManager` | **11.22.0** (security floor **11.11.0**) |
| Lint | Biome | `pnpm run lint` |
| i18n | `scripts/check-i18n-keys.mjs` | `pnpm run i18n:check` |
| Unit | Vitest + V8 | CI: `pnpm exec vitest run --coverage`; local: targeted `pnpm exec vitest run <path>` |
Expand Down
17 changes: 10 additions & 7 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,10 @@ inputs:
runs:
using: composite
steps:
# QNBS-v3: corepack enable ensures pnpm is available even if the runner has a different default.
# Node 24+ changed corepack behavior; explicit enable prevents "pnpm: command not found" errors.
- name: Enable corepack
shell: bash
run: corepack enable

- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
# QNBS-v3: install the patched exact pnpm before setup-node reads the repository lockfile for caching.
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
version: 11.22.0

# QNBS-v3: When node-version is set (matrix), use it; otherwise read .nvmrc so
# non-matrix jobs stay pinned to the project's declared Node version.
Expand All @@ -33,6 +30,12 @@ runs:
node-version-file: ${{ inputs.node-version == '' && inputs.node-version-file || '' }}
cache: pnpm

- name: Assert toolchain
run: |
export npm_config_user_agent="pnpm/$(pnpm --version)"
node scripts/check-pnpm-toolchain.mjs
shell: bash

- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
9 changes: 7 additions & 2 deletions .github/workflows/deploy-cloudflare-pages.yml.disabled
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,20 @@ jobs:
# QNBS-v3: SHA-pinned to prevent supply-chain attacks via mutable tags
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
version: 10
version: 11.22.0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm

- name: Assert toolchain
run: |
export npm_config_user_agent="pnpm/$(pnpm --version)"
node scripts/check-pnpm-toolchain.mjs

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The app supports a multi-provider AI stack (Gemini, OpenAI, Claude, Grok, OpenRo

| Layer | Technology |
|-------|------------|
| Runtime | Node.js `>=22.0.0` (`.nvmrc` → `22`), pnpm `>=11.0.0` (`packageManager: pnpm@11.5.2`) |
| Runtime | Node.js `>=22.0.0` (`.nvmrc` → `22`), pnpm `11.22.0` (`packageManager: pnpm@11.22.0`) |
| Framework | React `^19.2.7`, TypeScript 7 via `@typescript/native-preview` (tsgo, strict) — no pinned classic `typescript` package |
| Build tool | Vite `^8.0.16` (`vite.config.ts`) |
| Type checker | `tsgo` (TypeScript Go port) via `tsconfig.tsgo.json` with 4 checkers (`pnpm run typecheck`) |
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ This project follows the [Contributor Covenant Code of Conduct](.github/CODE_OF_

1. Install **Node.js 22+** LTS from [nodejs.org](https://nodejs.org/) (includes Corepack) or use **nvm-windows** and install `22` from [`.nvmrc`](.nvmrc).
2. Open **PowerShell or CMD as Administrator** once and run: `corepack enable`
3. In the repo folder: `corepack prepare pnpm@11.5.2 --activate` (version matches `packageManager` in [`package.json`](package.json); adjust if that field changes).
3. In the repo folder: `corepack prepare pnpm@11.22.0 --activate` (version matches `packageManager` in [`package.json`](package.json); adjust if that field changes).
4. Confirm: `pnpm -v` — then `pnpm install` and use `pnpm run …` for all scripts (hooks expect `pnpm` on `PATH`).

If `corepack` is not recognized, reinstall Node or enable the “Tools for Native Modules” / standard installation so `corepack.cmd` is on `PATH`.
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-2924_keys-0EA5E9" alt="i18n 19 locales — 2924 keys">
<img src="https://img.shields.io/badge/Tests-6908%2B_%2F_567_files-22C55E" alt="6908+ tests / 567 files">
<img src="https://img.shields.io/badge/Tests-6910%2B_%2F_568_files-22C55E" alt="6910+ tests / 568 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`) | 2924 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 (6908+ tests / 567 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (6910+ tests / 568 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 (6908+ tests, 567 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (6910+ tests, 568 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 @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
- **6908+ unit tests** across **567 test files** — CI is authoritative for pass/fail
- **6910+ unit tests** across **568 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: **2924 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
2 changes: 1 addition & 1 deletion docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ non-blocking under the exit criteria documented below. The coverage ratchet rema
`.github/actions/setup/action.yml` centralises pnpm + Node.js bootstrap into one reusable step used by every job:

```
pnpm/action-setup → actions/setup-node (cache: pnpm) → pnpm install --frozen-lockfile
pnpm/action-setup (explicit patched 11.22.0) → actions/setup-node (cache: pnpm) → toolchain check → pnpm install --frozen-lockfile
```

Each job that uses the composite must call `actions/checkout@v6` first (local composite actions are resolved from the workspace, so the repo must be checked out before `uses: ./.github/actions/setup` can be used). The `quality` job additionally passes `node-version: ${{ matrix.node-version }}` to cover the LTS matrix.
Expand Down
2 changes: 1 addition & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ gh run view <run-id> --log-failed
5. **Do not use** `npx wrangler deploy` (Workers) nor `wrangler pages deploy` in the deploy step — redundant and often fails on API token scope in the build container.
6. If the UI forces a deploy command, use: `pnpm run deploy:cloudflare` — it **exits 0** on Cloudflare (`CF_PAGES=1`) without calling Wrangler.
7. Remove **`CLOUDFLARE_API_TOKEN`** from Pages **build** environment variables unless you have a dedicated manual deploy workflow; it is not needed for Git-based Pages.
8. **Environment variables (build):** `NODE_VERSION=22`, `PNPM_VERSION=10` (or Corepack).
8. **Environment variables (build):** `NODE_VERSION=22`, `PNPM_VERSION=11.22.0` (or Corepack using the exact `packageManager` pin).
9. **Root:** repository root; **Package manager:** pnpm.

> **Status:** Optional GitHub workflow [`.github/workflows/deploy-cloudflare-pages.yml`](../.github/workflows/deploy-cloudflare-pages.yml) is **paused** (`if: false`). Prefer dashboard-only Pages deploy.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
"tauri"
],
"type": "module",
"packageManager": "pnpm@11.5.2",
"packageManager": "pnpm@11.22.0",
"engines": {
"node": ">=22.0.0",
"pnpm": "11.5.2"
"pnpm": "11.22.0"
},
"scripts": {
"hooks:install": "simple-git-hooks",
Expand Down
21 changes: 17 additions & 4 deletions scripts/check-pnpm-toolchain.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import process from 'node:process';
import { isVersionAtLeast } from './pnpm-version-policy.mjs';

const projectPackage = JSON.parse(
await (await import('node:fs/promises')).readFile('package.json', 'utf8'),
Expand All @@ -7,12 +8,25 @@ const expectedPackageManager = projectPackage.packageManager;
const expectedPnpmVersion = expectedPackageManager?.startsWith('pnpm@')
? expectedPackageManager.slice('pnpm@'.length)
: null;
const minimumSecurePnpmVersion = '11.11.0';

if (!expectedPnpmVersion) {
console.error('[toolchain] package.json must pin packageManager to pnpm@<exact-version>.');
process.exit(1);
}

if (projectPackage.engines?.pnpm !== expectedPnpmVersion) {
console.error('[toolchain] engines.pnpm must exactly match packageManager.');
process.exit(1);
}

if (!isVersionAtLeast(expectedPnpmVersion, minimumSecurePnpmVersion)) {
console.error(
`[toolchain] pnpm ${expectedPnpmVersion} is below the security floor ${minimumSecurePnpmVersion}.`,
);
process.exit(1);
}

const minimumNodeMajor = Number.parseInt(
projectPackage.engines?.node?.match(/\d+/)?.[0] ?? '0',
10,
Expand All @@ -27,10 +41,6 @@ if (actualNodeMajor < minimumNodeMajor) {

const isDirectHook = process.argv.includes('--hook');
const userAgentVersion = process.env.npm_config_user_agent?.match(/(?:^|\s)pnpm\/(\S+)/)?.[1];
if (userAgentVersion && userAgentVersion !== expectedPnpmVersion) {
console.error(`[toolchain] pnpm ${expectedPnpmVersion} is required; found ${userAgentVersion}.`);
process.exit(1);
}

if (!userAgentVersion) {
if (isDirectHook) {
Expand All @@ -43,6 +53,9 @@ if (!userAgentVersion) {
);
process.exit(1);
}
} else if (userAgentVersion !== expectedPnpmVersion) {
console.error(`[toolchain] pnpm ${expectedPnpmVersion} is required; found ${userAgentVersion}.`);
process.exit(1);
} else {
console.log(
`[toolchain] Node ${process.versions.node} and pnpm ${userAgentVersion} match the project pin.`,
Expand Down
2 changes: 2 additions & 0 deletions scripts/pnpm-version-policy.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export function parseVersion(version: string): number[] | null;
export function isVersionAtLeast(version: string, minimum: string): boolean;
19 changes: 19 additions & 0 deletions scripts/pnpm-version-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function parseVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
return match ? match.slice(1).map(Number) : null;
}

export function isVersionAtLeast(version, minimum) {
const actualParts = parseVersion(version);
const minimumParts = parseVersion(minimum);
if (!actualParts || !minimumParts) return false;

for (let index = 0; index < 3; index += 1) {
const actualPart = actualParts[index];
const minimumPart = minimumParts[index];
if (actualPart > minimumPart) return true;
if (actualPart < minimumPart) return false;
}

return true;
}
22 changes: 22 additions & 0 deletions tests/unit/pnpmToolchainPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { isVersionAtLeast } from '../../scripts/pnpm-version-policy.mjs';

// QNBS-v3: Compare semantic version components left-to-right so a lower major cannot pass on a higher minor.
describe('pnpm version policy', () => {
it.each([
['11.11.0', '11.11.0', true],
['11.12.0', '11.11.0', true],
['12.0.0', '11.11.0', true],
['11.10.9', '11.11.0', false],
['10.99.0', '11.0.0', false],
['11.22.0', '11.11.0', true],
])('%s >= %s is %s', (version, minimum, expected) => {
expect(isVersionAtLeast(version, minimum)).toBe(expected);
});

it('rejects malformed versions instead of treating them as secure', () => {
expect(isVersionAtLeast('11.22', '11.11.0')).toBe(false);
expect(isVersionAtLeast('11.22.0-beta.1', '11.11.0')).toBe(false);
});
});
41 changes: 41 additions & 0 deletions tests/unit/workflowPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,57 @@ import {

const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
const workflowPath = fileURLToPath(new URL('../../.github/workflows/ci.yml', import.meta.url));
const setupActionPath = fileURLToPath(
new URL('../../.github/actions/setup/action.yml', import.meta.url),
);
const cloudflareWorkflowPath = fileURLToPath(
new URL('../../.github/workflows/deploy-cloudflare-pages.yml.disabled', import.meta.url),
);
const scheduledSecurityWorkflowPath = fileURLToPath(
new URL('../../.github/workflows/security-scheduled.yml', import.meta.url),
);
const tauriManifestPath = fileURLToPath(new URL('../../src-tauri/Cargo.toml', import.meta.url));
const workflowSource = readFileSync(workflowPath, 'utf8');
const setupActionSource = readFileSync(setupActionPath, 'utf8');
const cloudflareWorkflowSource = readFileSync(cloudflareWorkflowPath, 'utf8');
const scheduledSecurityWorkflowSource = readFileSync(scheduledSecurityWorkflowPath, 'utf8');
const packageJson = JSON.parse(
readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'),
) as {
packageManager: string;
};
const tauriManifestSource = readFileSync(tauriManifestPath, 'utf8');

// QNBS-v3: Keep CI path and deployment authority policy executable against the real workflow files.
describe('CI workflow policy', () => {
// QNBS-v3: the first package-manager binary must be patched and explicit before repository caching/install.
it('bootstraps the exact secure pnpm before setup-node cache or install', () => {
const expectedVersion = packageJson.packageManager.replace('pnpm@', '');
const actionIndex = setupActionSource.indexOf('pnpm/action-setup@');
const nodeIndex = setupActionSource.indexOf('actions/setup-node@');
expect(setupActionSource).toContain(
'pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86',
);
expect(setupActionSource).toContain(`version: ${expectedVersion}`);
expect(actionIndex).toBeGreaterThanOrEqual(0);
expect(actionIndex).toBeLessThan(nodeIndex);
expect(setupActionSource).not.toContain('corepack enable');
const setupToolchainIndex = setupActionSource.indexOf(
'export npm_config_user_agent="pnpm/$(pnpm --version)"',
);
const setupInstallIndex = setupActionSource.indexOf('pnpm install --frozen-lockfile');
expect(setupToolchainIndex).toBeGreaterThanOrEqual(0);
expect(setupToolchainIndex).toBeLessThan(setupInstallIndex);

const cloudflareToolchainIndex = cloudflareWorkflowSource.indexOf(
'export npm_config_user_agent="pnpm/$(pnpm --version)"',
);
const cloudflareInstallIndex = cloudflareWorkflowSource.indexOf(
'pnpm install --frozen-lockfile',
);
expect(cloudflareToolchainIndex).toBeGreaterThanOrEqual(0);
expect(cloudflareToolchainIndex).toBeLessThan(cloudflareInstallIndex);
});
// QNBS-v3: preserve first-attempt Vitest failures as visible CI evidence instead of masking flakes with retries.
it('runs Vitest once so first-attempt failures cannot be hidden by retry', () => {
const vitestRun = workflowSource.match(/run:\s*pnpm exec vitest run[^\n]*/)?.[0];
Expand Down
Loading