diff --git a/README.md b/README.md
index fe5022aef..d2b0969c3 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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 (6959+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (6964+ tests / 576 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` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (6959+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (6964+ tests, 576 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -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):**
-- **6959+ unit tests** across **575 test files** — CI is authoritative for pass/fail
+- **6964+ unit tests** across **576 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)
diff --git a/scripts/ci-prepush-check-registry.d.mts b/scripts/ci-prepush-check-registry.d.mts
new file mode 100644
index 000000000..64cda4dd0
--- /dev/null
+++ b/scripts/ci-prepush-check-registry.d.mts
@@ -0,0 +1 @@
+export function shouldRunAdmissionCheck(name: string, files: readonly string[]): boolean;
diff --git a/scripts/ci-prepush-check-registry.mjs b/scripts/ci-prepush-check-registry.mjs
new file mode 100644
index 000000000..47b72ab56
--- /dev/null
+++ b/scripts/ci-prepush-check-registry.mjs
@@ -0,0 +1,41 @@
+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',
+]);
+
+export 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',
+ ]),
+ },
+ {
+ 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',
+ ]),
+ },
+]);
+
+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));
+}
diff --git a/scripts/ci-prepush-classifier.d.mts b/scripts/ci-prepush-classifier.d.mts
new file mode 100644
index 000000000..a12f63ab9
--- /dev/null
+++ b/scripts/ci-prepush-classifier.d.mts
@@ -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;
diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs
new file mode 100644
index 000000000..7a3c8ce23
--- /dev/null
+++ b/scripts/ci-prepush-classifier.mjs
@@ -0,0 +1,115 @@
+const DOC_FILE = /\.(?:md|mdx)$/i;
+const TS_FILE = /\.(?:c|m)?tsx?$/i;
+const WORKFLOW_ROOTS = ['.github/workflows/', '.github/actions/'];
+const RUST_ROOTS = ['src-tauri/', 'crates/'];
+const TOOLING_ROOTS = ['scripts/'];
+const TOOLING_FILES = new Set(['.gitleaks.toml']);
+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 (normalized.startsWith('tests/'))
+ return TS_FILE.test(normalized) ? 'TYPESCRIPT_APPLICATION' : 'TEST_ONLY';
+ if (TS_FILE.test(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';
+}
+
+// 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'].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;
+}
diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs
index 16c7c5b2c..952a10ca6 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -1,8 +1,16 @@
+import { spawnSync } from 'node:child_process';
import process from 'node:process';
+import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs';
+import {
+ classifyChangedFiles,
+ manualAdmissionNeedsFullValidation,
+ requiresTypecheck,
+} from './ci-prepush-classifier.mjs';
import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs';
import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs';
const evidenceIndex = process.argv.indexOf('--prepush-evidence-file');
+let evidenceChangedFiles;
if (evidenceIndex >= 0) {
try {
const evidence = resolvePushEvidence(
@@ -11,49 +19,118 @@ if (evidenceIndex >= 0) {
);
if (evidence.evidenceState !== 'RESOLVED')
throw new Error(evidence.reason ?? 'invalid evidence');
+ evidenceChangedFiles = evidence.changedFiles;
} catch (error) {
console.error(`[local-lowend] outgoing evidence rejected: ${error.message}`);
process.exit(1);
}
}
-const checks = [
- ['toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])],
- [
- 'typecheck (single checker)',
- // QNBS-v3: Make the low-end resource contract explicit; tsgo's default checker count is not a safe local default.
- () =>
- runLocalBinary('tsgo', ['--project', 'tsconfig.tsgo.json', '--noEmit', '--checkers', '1']),
- ],
- ['i18n key parity', () => runNodeScript('scripts/check-i18n-keys.mjs')],
- ['i18n bundle rebuild', () => runNodeScript('scripts/build-i18n.mjs')],
- ['i18n content guard', () => runNodeScript('scripts/content-guard.mjs')],
- [
- 'i18n translation quality',
- () =>
- runNodeScript('scripts/i18n-quality-report.mjs', [
- '--strict',
- '--min-coverage',
- '75',
- '--max-length-outliers',
- '8',
- ]),
- ],
- ['release/doc truth', () => runNodeScript('scripts/check-doc-metrics.mjs')],
- ['CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs')],
- ['desktop import boundary', () => runNodeScript('scripts/check-tauri-import-boundary.mjs')],
- ['native readiness', () => runNodeScript('scripts/check-native-readiness.mjs')],
-];
-
-if (!ensureDependencyState()) process.exit(1);
-
-for (const [name, run] of checks) {
- console.log(`[local-lowend] ${name}`);
+const fullRequested = process.argv.includes('--full');
+
+function gitRaw(args) {
+ const result = spawnSync('git', args, { encoding: 'utf8' });
+ if (result.status !== 0) return '';
+ return result.stdout ?? '';
+}
+
+function gitOptional(args) {
+ const result = spawnSync('git', args, { encoding: 'utf8' });
+ return result.status === 0 ? (result.stdout ?? '') : null;
+}
+
+function parseNulDelimitedPaths(output) {
+ return output.split('\0').filter(Boolean);
+}
+
+function changedFilesFromWorkingTree() {
+ return parseNulDelimitedPaths(
+ gitRaw(['diff', '--no-renames', '--name-only', '-z', 'HEAD']),
+ ).concat(parseNulDelimitedPaths(gitRaw(['ls-files', '--others', '--exclude-standard', '-z'])));
+}
+
+function changedFilesFromManualRange() {
+ const upstream = gitOptional(['rev-parse', '--verify', '@{upstream}'])?.trim();
+ if (upstream)
+ return {
+ files: parseNulDelimitedPaths(
+ gitRaw(['diff', '--no-renames', '--name-only', '-z', `${upstream}..HEAD`]),
+ ).concat(changedFilesFromWorkingTree()),
+ rangeResolved: true,
+ };
+
+ return { files: [], rangeResolved: false };
+}
+
+function report(name, status, detail = '') {
+ console.log(`[local-admission] ${name.padEnd(26)} ${status}${detail ? ` — ${detail}` : ''}`);
+ return status;
+}
+
+function runCheck(name, run) {
const status = run();
- if (status !== 0) {
- console.error(`[local-lowend] failed: ${name}`);
- process.exit(status);
- }
+ report(name, status === 0 ? 'PASS' : 'FAIL');
+ if (status !== 0) process.exit(status ?? 1);
+}
+
+const manualEvidence =
+ evidenceIndex >= 0
+ ? { files: evidenceChangedFiles, rangeResolved: true }
+ : changedFilesFromManualRange();
+const full =
+ fullRequested || (!manualEvidence.rangeResolved && manualAdmissionNeedsFullValidation(false));
+const files = manualEvidence.files;
+const classification = manualEvidence.rangeResolved
+ ? classifyChangedFiles(files)
+ : { kind: 'AMBIGUOUS', categories: ['UNKNOWN'], files: [] };
+const typecheckRequired = requiresTypecheck(classification, { full });
+
+console.log(`[local-admission] change class: ${classification.kind}`);
+console.log(`[local-admission] files considered: ${classification.files.length}`);
+if (!manualEvidence.rangeResolved && evidenceIndex < 0)
+ console.log(
+ '[local-admission] manual committed range unresolved; using conservative full admission',
+ );
+
+if (!ensureDependencyState()) {
+ report('Dependency state', 'FAIL');
+ process.exit(1);
+}
+report('Dependency state', 'PASS');
+
+runCheck('Toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook']));
+runCheck('Docs/release truth', () => runNodeScript('scripts/check-doc-metrics.mjs'));
+runCheck('CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs'));
+runCheck('Desktop import boundary', () => runNodeScript('scripts/check-tauri-import-boundary.mjs'));
+runCheck('Native readiness', () => runNodeScript('scripts/check-native-readiness.mjs'));
+
+if (shouldRunAdmissionCheck('i18n', classification.files) || full) {
+ runCheck('i18n key parity', () => runNodeScript('scripts/check-i18n-keys.mjs'));
+ runCheck('i18n bundle rebuild', () => runNodeScript('scripts/build-i18n.mjs'));
+ runCheck('i18n translation quality', () =>
+ runNodeScript('scripts/i18n-quality-report.mjs', [
+ '--strict',
+ '--min-coverage',
+ '75',
+ '--max-length-outliers',
+ '8',
+ ]),
+ );
+}
+
+if (shouldRunAdmissionCheck('contentGuard', classification.files) || full)
+ runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs'));
+
+if (typecheckRequired) {
+ runCheck('TypeScript (single checker)', () =>
+ // QNBS-v3: one checker bounds memory use on constrained developer machines.
+ runLocalBinary('tsgo', ['--project', 'tsconfig.tsgo.json', '--noEmit', '--checkers', '1']),
+ );
+} else {
+ report('TypeScript', 'DEFERRED_TO_REQUIRED_CI', 'no TypeScript-impacting changes detected');
}
-console.log('[local-lowend] pre-push checks passed sequentially.');
+console.log('\nLOCAL ADMISSION RESULT');
+console.log('Local checks completed sequentially.');
+console.log('Cloud validation required YES');
+console.log(`Classification ${classification.kind}`);
diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts
new file mode 100644
index 000000000..9f63a0f43
--- /dev/null
+++ b/tests/unit/tooling/ciPrepushClassifier.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { shouldRunAdmissionCheck } from '../../../scripts/ci-prepush-check-registry.mjs';
+import {
+ classifyChangedFiles,
+ classifyFile,
+ manualAdmissionNeedsFullValidation,
+ requiresTypecheck,
+} from '../../../scripts/ci-prepush-classifier.mjs';
+
+describe('change-aware local admission classifier', () => {
+ it.each([
+ ['README.md', 'DOCS'],
+ ['.github/workflows/ci.yml', 'WORKFLOW'],
+ ['tests/unit/example.test.ts', 'TYPESCRIPT_APPLICATION'],
+ ['tests/fixtures/example.json', 'TEST_ONLY'],
+ ['src-tauri/src/lib.rs', 'RUST_TAURI'],
+ ['scripts/check-example.mjs', 'TOOLING'],
+ ['unknown-extension.data', 'UNKNOWN'],
+ ])('classifies %s as %s', (file, expected) => {
+ expect(classifyFile(file)).toBe(expected);
+ });
+
+ it('runs TypeScript for TypeScript tests but defers non-TypeScript tests', () => {
+ const ts = classifyChangedFiles(['tests/unit/example.test.ts']);
+ const fixture = classifyChangedFiles(['tests/fixtures/example.json']);
+
+ expect(ts.kind).toBe('TYPESCRIPT_APPLICATION');
+ expect(requiresTypecheck(ts)).toBe(true);
+ expect(fixture.kind).toBe('TEST_ONLY');
+ expect(requiresTypecheck(fixture)).toBe(false);
+ });
+
+ it('uses the broad safe class for unknown or mixed changes', () => {
+ const unknown = classifyChangedFiles(['README.md', 'new-file.data']);
+ const mixed = classifyChangedFiles(['components/App.tsx', 'README.md']);
+
+ expect(unknown.kind).toBe('AMBIGUOUS');
+ expect(requiresTypecheck(unknown)).toBe(true);
+ expect(mixed.kind).toBe('MIXED');
+ expect(requiresTypecheck(mixed)).toBe(true);
+ });
+
+ it('routes only governed files and implementation changes to admission checks', () => {
+ expect(shouldRunAdmissionCheck('i18n', ['locales/en/common.json'])).toBe(true);
+ expect(shouldRunAdmissionCheck('i18n', ['README.md'])).toBe(false);
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/ci-prepush-classifier.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/build-i18n.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/i18n-quality-report.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('contentGuard', ['community-templates/index.json'])).toBe(true);
+ expect(shouldRunAdmissionCheck('contentGuard', ['README.md'])).toBe(false);
+ expect(shouldRunAdmissionCheck('i18n', ['scripts/ci-prepush-lowend.mjs'])).toBe(true);
+ expect(shouldRunAdmissionCheck('contentGuard', ['scripts/ci-prepush-lowend.mjs'])).toBe(true);
+ });
+
+ it('keeps TypeScript tooling files in the typecheck-required class', () => {
+ const classification = classifyChangedFiles([
+ 'scripts/check-tooling.ts',
+ 'scripts/types.d.mts',
+ ]);
+
+ expect(classification.kind).toBe('TYPESCRIPT_APPLICATION');
+ expect(requiresTypecheck(classification)).toBe(true);
+ });
+
+ it('requires conservative full admission when the manual range is unresolved', () => {
+ expect(manualAdmissionNeedsFullValidation(true)).toBe(false);
+ expect(manualAdmissionNeedsFullValidation(false)).toBe(true);
+
+ const earlierTypeScript = classifyChangedFiles(['src/app.tsx']);
+ const earlierI18n = classifyChangedFiles(['locales/en/common.json']);
+ const earlierContent = classifyChangedFiles(['community-templates/index.json']);
+
+ expect(requiresTypecheck(earlierTypeScript)).toBe(true);
+ expect(earlierI18n.kind).toBe('AMBIGUOUS');
+ expect(earlierContent.kind).toBe('AMBIGUOUS');
+ });
+});