From 0a4902f5c3e145d16c0d33c2713cad824079c927 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 02:11:15 +0200
Subject: [PATCH 1/4] refactor: make local admission change aware
---
README.md | 8 +-
scripts/ci-prepush-check-registry.d.mts | 1 +
scripts/ci-prepush-check-registry.mjs | 27 +++++
scripts/ci-prepush-classifier.d.mts | 26 ++++
scripts/ci-prepush-classifier.mjs | 111 +++++++++++++++++
scripts/ci-prepush-lowend.mjs | 114 ++++++++++++------
.../unit/tooling/ciPrepushClassifier.test.ts | 49 ++++++++
7 files changed, 295 insertions(+), 41 deletions(-)
create mode 100644 scripts/ci-prepush-check-registry.d.mts
create mode 100644 scripts/ci-prepush-check-registry.mjs
create mode 100644 scripts/ci-prepush-classifier.d.mts
create mode 100644 scripts/ci-prepush-classifier.mjs
create mode 100644 tests/unit/tooling/ciPrepushClassifier.test.ts
diff --git a/README.md b/README.md
index fe5022aef..db0fa0aae 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 (6962+ 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 (6962+ 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
+- **6962+ 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..f099c1966
--- /dev/null
+++ b/scripts/ci-prepush-check-registry.mjs
@@ -0,0 +1,27 @@
+const routingAuthority = 'scripts/ci-prepush-check-registry.mjs';
+const i18nPolicyFiles = new Set(['scripts/check-i18n-keys.mjs', 'scripts/i18n-locales.mjs']);
+
+export const admissionCheckRegistry = Object.freeze([
+ {
+ name: 'i18n',
+ matches: (file) =>
+ file.startsWith('locales/') ||
+ file.startsWith('public/locales/') ||
+ i18nPolicyFiles.has(file),
+ implementationFiles: new Set([routingAuthority, '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, '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..90e0188fa
--- /dev/null
+++ b/scripts/ci-prepush-classifier.d.mts
@@ -0,0 +1,26 @@
+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;
diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs
new file mode 100644
index 000000000..1f9f3df6d
--- /dev/null
+++ b/scripts/ci-prepush-classifier.mjs
@@ -0,0 +1,111 @@
+const DOC_FILE = /\.(?:md|mdx)$/i;
+const TS_FILE = /\.(?:c|m)?tsx?$|\.(?:c|m)?jsx?$/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 (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';
+ if (TS_FILE.test(normalized)) return 'TYPESCRIPT_APPLICATION';
+ 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);
+}
diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs
index 16c7c5b2c..cfa352501 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -1,4 +1,7 @@
+import { spawnSync } from 'node:child_process';
import process from 'node:process';
+import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs';
+import { classifyChangedFiles, requiresTypecheck } from './ci-prepush-classifier.mjs';
import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs';
import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs';
@@ -17,43 +20,80 @@ if (evidenceIndex >= 0) {
}
}
-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 full = process.argv.includes('--full');
+
+function gitRaw(args) {
+ const result = spawnSync('git', args, { encoding: 'utf8' });
+ if (result.status !== 0) return '';
+ return result.stdout ?? '';
+}
+
+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 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 files = changedFilesFromWorkingTree();
+const classification = classifyChangedFiles(files);
+const typecheckRequired = requiresTypecheck(classification, { full });
+
+console.log(`[local-admission] change class: ${classification.kind}`);
+console.log(`[local-admission] files considered: ${classification.files.length}`);
+
+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)', () =>
+ 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..19bef40ff
--- /dev/null
+++ b/tests/unit/tooling/ciPrepushClassifier.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { shouldRunAdmissionCheck } from '../../../scripts/ci-prepush-check-registry.mjs';
+import {
+ classifyChangedFiles,
+ classifyFile,
+ 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('contentGuard', ['community-templates/index.json'])).toBe(true);
+ expect(shouldRunAdmissionCheck('contentGuard', ['README.md'])).toBe(false);
+ });
+});
From 2d9efefd1a4a1ba61e2b6ecedc688b19c2f75cd6 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 02:25:45 +0200
Subject: [PATCH 2/4] fix: route committed changes through local admission
---
README.md | 8 ++++----
scripts/ci-prepush-check-registry.mjs | 7 ++++++-
scripts/ci-prepush-classifier.mjs | 4 ++--
scripts/ci-prepush-lowend.mjs | 4 +++-
tests/unit/tooling/ciPrepushClassifier.test.ts | 12 ++++++++++++
5 files changed, 27 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index db0fa0aae..480b572a4 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 (6962+ tests / 576 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (6963+ 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 (6962+ tests, 576 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (6963+ 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):**
-- **6962+ unit tests** across **576 test files** — CI is authoritative for pass/fail
+- **6963+ 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.mjs b/scripts/ci-prepush-check-registry.mjs
index f099c1966..0e7f79630 100644
--- a/scripts/ci-prepush-check-registry.mjs
+++ b/scripts/ci-prepush-check-registry.mjs
@@ -1,5 +1,10 @@
const routingAuthority = 'scripts/ci-prepush-check-registry.mjs';
-const i18nPolicyFiles = new Set(['scripts/check-i18n-keys.mjs', 'scripts/i18n-locales.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([
{
diff --git a/scripts/ci-prepush-classifier.mjs b/scripts/ci-prepush-classifier.mjs
index 1f9f3df6d..871259d5c 100644
--- a/scripts/ci-prepush-classifier.mjs
+++ b/scripts/ci-prepush-classifier.mjs
@@ -1,5 +1,5 @@
const DOC_FILE = /\.(?:md|mdx)$/i;
-const TS_FILE = /\.(?:c|m)?tsx?$|\.(?:c|m)?jsx?$/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/'];
@@ -59,6 +59,7 @@ export function classifyFile(file) {
}
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) ||
@@ -68,7 +69,6 @@ export function classifyFile(file) {
return 'DEPENDENCY_TOOLCHAIN';
}
if (BUILD_CONFIG_FILES.has(base)) return 'BUILD_CONFIGURATION';
- if (TS_FILE.test(normalized)) return 'TYPESCRIPT_APPLICATION';
return 'UNKNOWN';
}
diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs
index cfa352501..7a1b7080a 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -6,6 +6,7 @@ import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/sh
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(
@@ -14,6 +15,7 @@ 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);
@@ -49,7 +51,7 @@ function runCheck(name, run) {
if (status !== 0) process.exit(status ?? 1);
}
-const files = changedFilesFromWorkingTree();
+const files = evidenceIndex >= 0 ? evidenceChangedFiles : changedFilesFromWorkingTree();
const classification = classifyChangedFiles(files);
const typecheckRequired = requiresTypecheck(classification, { full });
diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts
index 19bef40ff..7457ae466 100644
--- a/tests/unit/tooling/ciPrepushClassifier.test.ts
+++ b/tests/unit/tooling/ciPrepushClassifier.test.ts
@@ -43,7 +43,19 @@ describe('change-aware local admission classifier', () => {
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);
});
+
+ 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);
+ });
});
From bc0043108ce83e1041540b2c35f4f5c4a004adf4 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 04:25:11 +0200
Subject: [PATCH 3/4] fix: close local admission routing gaps
---
scripts/ci-prepush-check-registry.mjs | 13 +++++--
scripts/ci-prepush-lowend.mjs | 34 ++++++++++++++++++-
.../unit/tooling/ciPrepushClassifier.test.ts | 2 ++
3 files changed, 46 insertions(+), 3 deletions(-)
diff --git a/scripts/ci-prepush-check-registry.mjs b/scripts/ci-prepush-check-registry.mjs
index 0e7f79630..47b72ab56 100644
--- a/scripts/ci-prepush-check-registry.mjs
+++ b/scripts/ci-prepush-check-registry.mjs
@@ -1,4 +1,5 @@
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',
@@ -13,7 +14,11 @@ export const admissionCheckRegistry = Object.freeze([
file.startsWith('locales/') ||
file.startsWith('public/locales/') ||
i18nPolicyFiles.has(file),
- implementationFiles: new Set([routingAuthority, 'scripts/ci-prepush-classifier.mjs']),
+ implementationFiles: new Set([
+ routingAuthority,
+ runnerAuthority,
+ 'scripts/ci-prepush-classifier.mjs',
+ ]),
},
{
name: 'contentGuard',
@@ -21,7 +26,11 @@ export const admissionCheckRegistry = Object.freeze([
file === 'scripts/content-guard.mjs' ||
file.startsWith('community-templates/') ||
file.startsWith('public/community-templates/'),
- implementationFiles: new Set([routingAuthority, 'scripts/ci-prepush-classifier.mjs']),
+ implementationFiles: new Set([
+ routingAuthority,
+ runnerAuthority,
+ 'scripts/ci-prepush-classifier.mjs',
+ ]),
},
]);
diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs
index 7a1b7080a..8ca88f7e7 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -30,6 +30,18 @@ function gitRaw(args) {
return result.stdout ?? '';
}
+function gitRequired(args, description) {
+ const result = spawnSync('git', args, { encoding: 'utf8' });
+ if (result.status !== 0)
+ throw new Error(`${description} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`);
+ 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);
}
@@ -40,6 +52,25 @@ function changedFilesFromWorkingTree() {
).concat(parseNulDelimitedPaths(gitRaw(['ls-files', '--others', '--exclude-standard', '-z'])));
}
+function changedFilesFromManualRange() {
+ const upstream = gitOptional(['rev-parse', '--verify', '@{upstream}'])?.trim();
+ if (upstream)
+ return parseNulDelimitedPaths(
+ gitRequired(
+ ['diff', '--no-renames', '--name-only', '-z', `${upstream}..HEAD`],
+ 'resolve manual committed range',
+ ),
+ ).concat(changedFilesFromWorkingTree());
+
+ const head = gitRequired(['rev-parse', '--verify', 'HEAD'], 'resolve HEAD').trim();
+ return parseNulDelimitedPaths(
+ gitRequired(
+ ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', head],
+ 'resolve manual HEAD changes',
+ ),
+ ).concat(changedFilesFromWorkingTree());
+}
+
function report(name, status, detail = '') {
console.log(`[local-admission] ${name.padEnd(26)} ${status}${detail ? ` — ${detail}` : ''}`);
return status;
@@ -51,7 +82,7 @@ function runCheck(name, run) {
if (status !== 0) process.exit(status ?? 1);
}
-const files = evidenceIndex >= 0 ? evidenceChangedFiles : changedFilesFromWorkingTree();
+const files = evidenceIndex >= 0 ? evidenceChangedFiles : changedFilesFromManualRange();
const classification = classifyChangedFiles(files);
const typecheckRequired = requiresTypecheck(classification, { full });
@@ -89,6 +120,7 @@ if (shouldRunAdmissionCheck('contentGuard', classification.files) || full)
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 {
diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts
index 7457ae466..81be3fe03 100644
--- a/tests/unit/tooling/ciPrepushClassifier.test.ts
+++ b/tests/unit/tooling/ciPrepushClassifier.test.ts
@@ -47,6 +47,8 @@ describe('change-aware local admission classifier', () => {
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', () => {
From fd697ab914e843fc25b8974893e8aa96cf4cef11 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Tue, 25 Aug 2026 08:20:49 +0200
Subject: [PATCH 4/4] fix: fail safe on unresolved manual ranges
---
README.md | 8 +--
scripts/ci-prepush-classifier.d.mts | 1 +
scripts/ci-prepush-classifier.mjs | 4 ++
scripts/ci-prepush-lowend.mjs | 53 ++++++++++---------
.../unit/tooling/ciPrepushClassifier.test.ts | 14 +++++
5 files changed, 51 insertions(+), 29 deletions(-)
diff --git a/README.md b/README.md
index 480b572a4..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 (6963+ tests / 576 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 (6963+ tests, 576 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):**
-- **6963+ unit tests** across **576 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-classifier.d.mts b/scripts/ci-prepush-classifier.d.mts
index 90e0188fa..a12f63ab9 100644
--- a/scripts/ci-prepush-classifier.d.mts
+++ b/scripts/ci-prepush-classifier.d.mts
@@ -24,3 +24,4 @@ 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
index 871259d5c..7a3c8ce23 100644
--- a/scripts/ci-prepush-classifier.mjs
+++ b/scripts/ci-prepush-classifier.mjs
@@ -109,3 +109,7 @@ export function requiresTypecheck(classification, { full = false } = {}) {
'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 8ca88f7e7..952a10ca6 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -1,7 +1,11 @@
import { spawnSync } from 'node:child_process';
import process from 'node:process';
import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs';
-import { classifyChangedFiles, requiresTypecheck } from './ci-prepush-classifier.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';
@@ -22,7 +26,7 @@ if (evidenceIndex >= 0) {
}
}
-const full = process.argv.includes('--full');
+const fullRequested = process.argv.includes('--full');
function gitRaw(args) {
const result = spawnSync('git', args, { encoding: 'utf8' });
@@ -30,13 +34,6 @@ function gitRaw(args) {
return result.stdout ?? '';
}
-function gitRequired(args, description) {
- const result = spawnSync('git', args, { encoding: 'utf8' });
- if (result.status !== 0)
- throw new Error(`${description} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`);
- return result.stdout ?? '';
-}
-
function gitOptional(args) {
const result = spawnSync('git', args, { encoding: 'utf8' });
return result.status === 0 ? (result.stdout ?? '') : null;
@@ -55,20 +52,14 @@ function changedFilesFromWorkingTree() {
function changedFilesFromManualRange() {
const upstream = gitOptional(['rev-parse', '--verify', '@{upstream}'])?.trim();
if (upstream)
- return parseNulDelimitedPaths(
- gitRequired(
- ['diff', '--no-renames', '--name-only', '-z', `${upstream}..HEAD`],
- 'resolve manual committed range',
- ),
- ).concat(changedFilesFromWorkingTree());
-
- const head = gitRequired(['rev-parse', '--verify', 'HEAD'], 'resolve HEAD').trim();
- return parseNulDelimitedPaths(
- gitRequired(
- ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', head],
- 'resolve manual HEAD changes',
- ),
- ).concat(changedFilesFromWorkingTree());
+ 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 = '') {
@@ -82,12 +73,24 @@ function runCheck(name, run) {
if (status !== 0) process.exit(status ?? 1);
}
-const files = evidenceIndex >= 0 ? evidenceChangedFiles : changedFilesFromManualRange();
-const classification = classifyChangedFiles(files);
+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');
diff --git a/tests/unit/tooling/ciPrepushClassifier.test.ts b/tests/unit/tooling/ciPrepushClassifier.test.ts
index 81be3fe03..9f63a0f43 100644
--- a/tests/unit/tooling/ciPrepushClassifier.test.ts
+++ b/tests/unit/tooling/ciPrepushClassifier.test.ts
@@ -3,6 +3,7 @@ import { shouldRunAdmissionCheck } from '../../../scripts/ci-prepush-check-regis
import {
classifyChangedFiles,
classifyFile,
+ manualAdmissionNeedsFullValidation,
requiresTypecheck,
} from '../../../scripts/ci-prepush-classifier.mjs';
@@ -60,4 +61,17 @@ describe('change-aware local admission classifier', () => {
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');
+ });
});