Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
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;
}
112 changes: 74 additions & 38 deletions scripts/ci-prepush-lowend.mjs
Original file line number Diff line number Diff line change
@@ -1,59 +1,95 @@
import process from 'node:process';
import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs';
import {
classifyChangedFiles,
manualAdmissionNeedsFullValidation,
requiresTypecheck,
} from './ci-prepush-classifier.mjs';
import { isMainModule, resolveManualEvidence } from './ci-prepush-range-resolver.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');
if (evidenceIndex >= 0) {
function report(name, status, detail = '') {
console.log(`[local-admission] ${name.padEnd(26)} ${status}${detail ? ` — ${detail}` : ''}`);
return status;
}

function runCheck(name, run) {
const status = run();
report(name, status === 0 ? 'PASS' : 'FAIL');
if (status !== 0) process.exit(status ?? 1);
}

function main() {
const evidenceIndex = process.argv.indexOf('--prepush-evidence-file');
const evidenceFile = evidenceIndex >= 0 ? process.argv[evidenceIndex + 1] : undefined;
const fullRequested = process.argv.includes('--full');

let manualEvidence;
try {
const evidence = resolvePushEvidence(
readPrePushEvidenceFile(process.argv[evidenceIndex + 1]),
process.cwd(),
);
if (evidence.evidenceState !== 'RESOLVED')
throw new Error(evidence.reason ?? 'invalid evidence');
manualEvidence = resolveManualEvidence(evidenceFile);
} 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',
() =>
const full = fullRequested || manualAdmissionNeedsFullValidation(manualEvidence.rangeResolved);
const classification = manualEvidence.rangeResolved
? classifyChangedFiles(manualEvidence.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)
console.log(
'[local-admission] change evidence incomplete or 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) {
Comment thread
qnbs marked this conversation as resolved.
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',
]),
],
['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);
if (shouldRunAdmissionCheck('contentGuard', classification.files) || full)
runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs'));

for (const [name, run] of checks) {
console.log(`[local-lowend] ${name}`);
const status = run();
if (status !== 0) {
console.error(`[local-lowend] failed: ${name}`);
process.exit(status);
if (typecheckRequired) {
Comment thread
qnbs marked this conversation as resolved.
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('\nLOCAL ADMISSION RESULT');
console.log('Local checks completed sequentially.');
console.log('Cloud validation required YES');
console.log(`Classification ${classification.kind}`);
}

console.log('[local-lowend] pre-push checks passed sequentially.');
// QNBS-v3: guard execution so this module can be imported for testing without running the CLI.
if (isMainModule(process.argv[1], import.meta.url)) main();
Loading
Loading