Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
18 changes: 12 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,18 @@ procedure.
### Philosophy

- **Cloud CI-first:** The canonical quality gate is GitHub Actions. Low-end local machines should run only the "Quick" tier.
- **Quick tier (local, before every push):** `pnpm run ci:prepush` runs the project typecheck with
one checker, i18n parity/quality/bundle/content checks, release/doc truth, and lightweight desktop guardrails sequentially;
the pre-commit hook separately runs staged-file Biome checks. Run the gate again after every
correction before re-pushing; do not
push based only on a targeted test or a changed-file lint run. Optionally:
`pnpm exec vitest run <path>` **without** `--coverage`.
- **Quick tier (local, before every push):** `pnpm run ci:prepush` always resolves a change-aware
classification from the outgoing evidence, then runs release/doc truth and lightweight desktop
guardrails sequentially unconditionally. The one-checker project typecheck and i18n
parity/quality/bundle/content checks run only when the classification requires them —
`DOCS_ONLY`/`WORKFLOW_ONLY`/`NON_CODE_ONLY`/`RUST_TAURI`/`TOOLING`/non-TypeScript `TEST_ONLY`
changes report typecheck as deferred to required CI instead, and i18n/content-guard checks run
Comment thread
qnbs marked this conversation as resolved.
only for their own governed paths; incomplete or unresolved path evidence fails closed into
running everything. Required GitHub CI remains the unconditional authority for the full
typecheck and i18n validation. The pre-commit hook separately runs staged-file Biome checks. Run
the gate again after every correction before re-pushing; do not push based only on a targeted
test or a changed-file lint run. Optionally: `pnpm exec vitest run <path>` **without**
`--coverage`.
- **Dependency state:** `pnpm run deps:verify` compares a content fingerprint of dependency
manifests, workspace package manifests, and patches. After a dependency-related branch switch,
run `node scripts/dependency-state.mjs reconcile` (or `pnpm run deps:reconcile` when pnpm can
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b

**Vitest watch-mode hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper. Always use an explicit targeted `pnpm exec vitest run <path>` command; watch mode hangs the constrained development hardware.

**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It runs the full repository lint, then the exact CI typecheck and i18n checks sequentially. A targeted test or changed-file lint run alone is insufficient. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm install --frozen-lockfile` first. The pre-commit hook does not replace this gate.
**Mandatory pre-push gate:** Run `pnpm run ci:prepush` before every push and again after every local correction before re-pushing. It always resolves a change-aware classification (`scripts/ci-prepush-classifier.mjs`) from the outgoing evidence first, then runs lint, docs/CSP/native-readiness guardrails, and dependency-state checks unconditionally. The exact CI typecheck and the i18n/content-guard checks run only when the classification requires them — `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, and non-TypeScript `TEST_ONLY` changes report typecheck as `DEFERRED_TO_REQUIRED_CI` instead of running it locally, and i18n/content-guard checks run only for changes matching their own governed paths or implementation files (see `scripts/ci-prepush-check-registry.mjs`). Whenever outgoing path evidence is incomplete, unresolved, or the manual committed-range diff fails, the gate fails closed into full local admission (every check runs) rather than deferring anything. A targeted test or changed-file lint run alone is insufficient. If pnpm reports dependency verification after a branch or lockfile change, run `pnpm install --frozen-lockfile` first. The pre-commit hook does not replace this gate. Required GitHub CI remains the unconditional authority for the complete TypeScript and i18n validation regardless of what the local gate deferred.

**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs the full repository lint followed by the exact CI typecheck and i18n checks; CI additionally runs full-suite coverage and heavy jobs. Locally use only the targeted form `pnpm exec vitest run <path> --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).
**Quality gate (local pre-push subset):** `pnpm run ci:prepush` runs the full repository lint plus dependency-state/docs/CSP/native-readiness checks unconditionally, and the exact CI typecheck and i18n/content-guard checks only for changes the classifier marks as potentially impacting them (fail-closed to "run everything" when evidence is incomplete — see the pre-push gate note above); CI additionally runs full-suite coverage and heavy jobs regardless of what the local gate ran. Locally use only the targeted form `pnpm exec vitest run <path> --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`).

**CI pipeline order:** `security` → `quality` (Biome + tsgo + Vitest matrix) → `build` / `e2e` / `storybook` (parallel) → `lighthouse` (after build) → `deploy` on `main`. `ci-success` is a required-status aggregator (`needs: [security, quality, build]`) so branch protection can require one context instead of three/four individual ones — see `docs/CI.md`. Two additional jobs run in parallel with `quality`, both path-scoped via the `changes` job (legitimately `skipping` on PRs that don't touch their directory, which `ci-success` treats as a pass for that job only): `rust-tauri` (`src-tauri/**` — fmt/check/clippy/test, needs the GTK/WebKit apt-get steps) and `core-rust` (`crates/**` — same fmt/check/clippy/test for the renderer-neutral Rust Core, no GUI deps so no apt-get steps needed).

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,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-2925_keys-0EA5E9" alt="i18n 19 locales — 2925 keys">
<img src="https://img.shields.io/badge/Tests-6963%2B_%2F_575_files-22C55E" alt="6963+ tests / 575 files">
<img src="https://img.shields.io/badge/Tests-6984%2B_%2F_577_files-22C55E" alt="6984+ tests / 577 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 @@ -512,7 +512,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`) | 2925 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 (6963+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (6984+ tests / 577 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 @@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (6963+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (6984+ tests, 577 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 @@ -712,7 +712,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):**
- **6963+ unit tests** across **575 test files** — CI is authoritative for pass/fail
- **6984+ unit tests** across **577 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: **2925 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
21 changes: 21 additions & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,27 @@ pnpm exec vitest run <path> # optional targeted smoke, no coverage
Playwright E2E, Lighthouse, Storybook, and full-suite coverage are intentionally omitted from
the local block above; GitHub Actions owns those heavy checks on this hardware.

### `ci:prepush` change-aware routing

`pnpm run ci:prepush` always resolves a change classification from the outgoing evidence first
(`scripts/ci-prepush-classifier.mjs`), then runs lint, docs/release-truth, CSP, desktop-import
boundary, native-readiness, and dependency-state checks unconditionally on every invocation. Two
Comment thread
qnbs marked this conversation as resolved.
Outdated
check groups are conditional on that classification instead of always running:

- **TypeScript (single-checker)** — skipped, reporting `DEFERRED_TO_REQUIRED_CI`, when the
classification is `DOCS_ONLY`, `WORKFLOW_ONLY`, `NON_CODE_ONLY`, `RUST_TAURI`, `TOOLING`, or
non-TypeScript `TEST_ONLY`. Runs for every other classification, including `AMBIGUOUS`/`MIXED`.
- **i18n (key parity, bundle rebuild, translation quality) and content-guard** — run only when the
changed files match their own governed paths or implementation files
(`scripts/ci-prepush-check-registry.mjs`), independent of the TypeScript decision above.

**Fail-closed by design:** whenever outgoing path evidence is incomplete — the manual committed
range can't be resolved, a Git diff command fails, or pre-push-hook evidence reports partial path
completeness (e.g. a tag-only push) — the gate does not defer anything; it falls back to running
every check, matching a `--full` invocation. Deferring a check locally never changes what required
GitHub CI validates: the complete TypeScript and i18n checks always run in CI regardless of what
the local gate ran or deferred, and CI remains the merge authority.

On standard hardware, or when debugging a build-affecting change, run the build-specific checks
separately; CI remains authoritative for the complete build and artifact checks:

Expand Down
1 change: 1 addition & 0 deletions scripts/ci-prepush-check-registry.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function shouldRunAdmissionCheck(name: string, files: readonly string[]): boolean;
Comment thread
qnbs marked this conversation as resolved.
44 changes: 44 additions & 0 deletions scripts/ci-prepush-check-registry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const routingAuthority = 'scripts/ci-prepush-check-registry.mjs';
const runnerAuthority = 'scripts/ci-prepush-lowend.mjs';
const i18nPolicyFiles = new Set([
'scripts/check-i18n-keys.mjs',
'scripts/i18n-locales.mjs',
'scripts/build-i18n.mjs',
'scripts/i18n-quality-report.mjs',
]);

// QNBS-v3: not exported — shouldRunAdmissionCheck is the public API, nothing else consumes this.
const admissionCheckRegistry = Object.freeze([
{
name: 'i18n',
matches: (file) =>
file.startsWith('locales/') ||
file.startsWith('public/locales/') ||
i18nPolicyFiles.has(file),
implementationFiles: new Set([
routingAuthority,
runnerAuthority,
'scripts/ci-prepush-classifier.mjs',
'scripts/ci-prepush-range-resolver.mjs',
]),
Comment thread
qnbs marked this conversation as resolved.
},
{
name: 'contentGuard',
matches: (file) =>
file === 'scripts/content-guard.mjs' ||
file.startsWith('community-templates/') ||
file.startsWith('public/community-templates/'),
implementationFiles: new Set([
routingAuthority,
runnerAuthority,
'scripts/ci-prepush-classifier.mjs',
'scripts/ci-prepush-range-resolver.mjs',
]),
Comment thread
qnbs marked this conversation as resolved.
},
]);

export function shouldRunAdmissionCheck(name, files) {
const entry = admissionCheckRegistry.find((candidate) => candidate.name === name);
if (!entry) throw new Error(`unknown local admission check: ${name}`);
return files.some((file) => entry.matches(file) || entry.implementationFiles.has(file));
}
27 changes: 27 additions & 0 deletions scripts/ci-prepush-classifier.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type ChangeKind =
| 'NO_CHANGES'
| 'DOCS_ONLY'
| 'WORKFLOW_ONLY'
| 'NON_CODE_ONLY'
| 'RUST_TAURI'
| 'TOOLING'
| 'TEST_ONLY'
| 'TYPESCRIPT_APPLICATION'
| 'DEPENDENCY_TOOLCHAIN'
| 'BUILD_CONFIGURATION'
| 'AMBIGUOUS'
| 'MIXED';

export interface ChangeClassification {
readonly kind: ChangeKind;
readonly categories: readonly string[];
readonly files: readonly string[];
}

export function classifyFile(file: string): string;
export function classifyChangedFiles(files: readonly string[]): ChangeClassification;
export function requiresTypecheck(
classification: ChangeClassification,
options?: { readonly full?: boolean },
): boolean;
export function manualAdmissionNeedsFullValidation(rangeResolved: boolean): boolean;
132 changes: 132 additions & 0 deletions scripts/ci-prepush-classifier.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
const DOC_FILE = /\.(?:md|mdx)$/i;
const TS_FILE = /\.(?:c|m)?tsx?$/i;
const WORKFLOW_ROOTS = ['.github/workflows/', '.github/actions/'];
// QNBS-v3: matches the i18n/contentGuard registry's own routing so both stay in sync.
const NON_CODE_ROOTS = [
'locales/',
'public/locales/',
'community-templates/',
'public/community-templates/',
];
const RUST_ROOTS = ['src-tauri/', 'crates/'];
const TOOLING_ROOTS = ['scripts/'];
const TOOLING_FILES = new Set(['.gitleaks.toml']);
// QNBS-v3: these scripts/*.json files are imported into TS config with resolveJsonModule, not tooling.
const TYPED_CONFIG_INPUTS = new Set(['scripts/coverage-thresholds.json']);
const DEPENDENCY_FILES = new Set([
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'.npmrc',
'.nvmrc',
'rust-toolchain',
'rust-toolchain.toml',
]);
const BUILD_CONFIG_FILES = new Set([
'biome.json',
'index.html',
'playwright.config.ts',
'postcss.config.js',
'postcss.config.mjs',
'tailwind.config.js',
'tailwind.config.ts',
'turbo.json',
'vite.config.ts',
'vitest.config.ts',
]);

function startsWithRoot(file, roots) {
return roots.some((root) => file.startsWith(root));
}

function normalizePath(file) {
return file.replaceAll('\\', '/').replace(/^\.\//, '');
}

function isInstructionFile(file) {
return (
file === 'AGENTS.md' ||
file === 'CLAUDE.md' ||
file === '.cursorrules' ||
file === '.github/copilot-instructions.md' ||
file.startsWith('.cursor/rules/')
);
}

export function classifyFile(file) {
const normalized = normalizePath(file);
const base = normalized.split('/').at(-1) ?? normalized;

if (startsWithRoot(normalized, WORKFLOW_ROOTS)) return 'WORKFLOW';
if (DOC_FILE.test(normalized) || isInstructionFile(normalized)) return 'DOCS';
if (
RUST_ROOTS.some((root) => normalized.startsWith(root)) ||
/(?:^|\/)(?:Cargo\.toml|Cargo\.lock)$/.test(normalized) ||
normalized.endsWith('.rs')
) {
return 'RUST_TAURI';
}
if (startsWithRoot(normalized, NON_CODE_ROOTS)) return 'NON_CODE_ONLY';
// QNBS-v3: tests/fixtures/**/*.json gets inferred TS types (e.g. logger.test.ts) — typecheck it.
if (normalized.startsWith('tests/'))
return TS_FILE.test(normalized) ||
(normalized.startsWith('tests/fixtures/') && normalized.endsWith('.json'))
? 'TYPESCRIPT_APPLICATION'
: 'TEST_ONLY';
if (TS_FILE.test(normalized)) return 'TYPESCRIPT_APPLICATION';
if (TYPED_CONFIG_INPUTS.has(normalized)) return 'TYPESCRIPT_APPLICATION';
if (TOOLING_FILES.has(normalized) || startsWithRoot(normalized, TOOLING_ROOTS)) return 'TOOLING';
if (
DEPENDENCY_FILES.has(base) ||
normalized.startsWith('patches/') ||
(normalized.startsWith('packages/') && base === 'package.json')
) {
return 'DEPENDENCY_TOOLCHAIN';
}
if (BUILD_CONFIG_FILES.has(base)) return 'BUILD_CONFIGURATION';
return 'UNKNOWN';
}
Comment thread
qnbs marked this conversation as resolved.

// QNBS-v3: classify change impact before starting expensive local checks.
export function classifyChangedFiles(files) {
const normalizedFiles = [...new Set(files.map(normalizePath).filter(Boolean))].sort();
const categories = [...new Set(normalizedFiles.map(classifyFile))];

if (normalizedFiles.length === 0)
return { kind: 'NO_CHANGES', categories, files: normalizedFiles };
if (categories.every((category) => category === 'DOCS'))
return { kind: 'DOCS_ONLY', categories, files: normalizedFiles };
if (categories.every((category) => category === 'WORKFLOW'))
return { kind: 'WORKFLOW_ONLY', categories, files: normalizedFiles };
if (categories.length === 1) {
if (categories[0] === 'UNKNOWN')
return { kind: 'AMBIGUOUS', categories, files: normalizedFiles };
return { kind: categories[0], categories, files: normalizedFiles };
}
if (
categories.every((category) =>
['DOCS', 'WORKFLOW', 'TOOLING', 'TEST_ONLY', 'NON_CODE_ONLY'].includes(category),
)
)
return { kind: 'NON_CODE_ONLY', categories, files: normalizedFiles };
if (categories.includes('UNKNOWN'))
return { kind: 'AMBIGUOUS', categories, files: normalizedFiles };
return { kind: 'MIXED', categories, files: normalizedFiles };
}

export function requiresTypecheck(classification, { full = false } = {}) {
if (full) return true;
return ![
'NO_CHANGES',
'DOCS_ONLY',
'WORKFLOW_ONLY',
'NON_CODE_ONLY',
'RUST_TAURI',
'TOOLING',
'TEST_ONLY',
].includes(classification.kind);
}

export function manualAdmissionNeedsFullValidation(rangeResolved) {
return !rangeResolved;
}
Loading
Loading